From 5b3b93f74c05f81d16b38078c3b321c850a66120 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 3 Aug 2026 17:24:43 -0700 Subject: [PATCH 01/35] Python SDK: add search_helpers for keyless /v1/agents/search endpoint Hand-maintained search_helpers.py (mirrors research_helpers.py pattern) that POSTs to /v1/agents/search instead of /v1/search. The agents-search endpoint is a proxy that: - With an API key -> forwards to /v1/search unrestricted (full features) - Without a key -> free tier (count <= 50, no livecrawl, IP rate-limited; returns 402 on any limit) - FreeTierLimitError exception for 402 responses - search() / search_async() standalone helpers accepting string enum params - 14 unit tests covering keyed/keyless, 402/401/422/500/4XX branches DX-694 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/search_helpers.py | 340 ++++++++++++++++++++++++++++++++ tests/test_search_helpers.py | 154 +++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 src/youdotcom/search_helpers.py create mode 100644 tests/test_search_helpers.py diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py new file mode 100644 index 0000000..613085a --- /dev/null +++ b/src/youdotcom/search_helpers.py @@ -0,0 +1,340 @@ +"""Hand-maintained search helpers targeting ``/v1/agents/search``. + +This module is NOT regenerated by Speakeasy. It mirrors the generated +``search_post`` request machinery but POSTs to ``/v1/agents/search`` instead +of ``/v1/search``. The agents-search endpoint is a proxy that: + +- **With an API key** → forwards to ``/v1/search`` unrestricted (full features). +- **Without a key** → free tier: IP-rate-limited, ``count`` capped at 1–50, + ``livecrawl`` not allowed. Returns ``402`` on any limit. + +Use this as the default search entrypoint for skills, plugins, and MCP tools. +The generated ``search.unified`` / ``search_post`` remain available for callers +who need the raw ``/v1/search`` endpoint. + +Re-apply this file after Speakeasy regen (precedent: ``research_helpers.py``, +``utils/security.py``). +""" + +from __future__ import annotations + +from typing import Any, Iterable, List, Mapping, Optional + +from youdotcom import errors, models, utils +from youdotcom._hooks import HookContext +from youdotcom.sdk import You +from youdotcom.types import OptionalNullable, UNSET +from youdotcom.utils import get_security_from_env +from youdotcom.utils.unmarshal_json_response import unmarshal_json_response + + +class FreeTierLimitError(Exception): + """Raised when the keyless free-tier search endpoint returns HTTP 402. + + Carries the raw response ``body`` so callers can surface the upgrade + message / URL. + """ + + status_code: int + body: Optional[str] + + def __init__(self, message: str, *, body: Optional[str] = None) -> None: + super().__init__(message) + self.status_code = 402 + self.body = body + + +def search( + client: You, + *, + query: str, + count: Optional[int] = 10, + freshness: Optional[str] = None, + offset: Optional[int] = None, + country: Optional[str] = None, + language: Optional[str] = None, + safesearch: Optional[str] = None, + livecrawl: Optional[str] = None, + livecrawl_formats: Optional[Iterable[str]] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + crawl_timeout: Optional[int] = 10, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, +) -> models.SearchResponse: + r"""Search via ``POST /v1/agents/search`` (keyless-capable default). + + With no API key configured on ``client``, runs in the free tier + (count ≤ 50, no livecrawl). A ``402`` response raises + :class:`FreeTierLimitError` carrying the upgrade message. + + Enum-typed parameters (``country``, ``safesearch``, ``livecrawl``, + ``freshness``) accept plain strings — pydantic coerces them when building + the request body, so callers don't need to import enum classes. + + :param client: A ``You`` SDK client (keyed or keyless). + :param query: The search query. + :param count: Max results per section (1–50 on the free tier). + :param freshness: ``"day"``, ``"week"``, ``"month"``, ``"year"``, or + ``"YYYY-MM-DDtoYYYY-MM-DD"``. + :param offset: Pagination offset (multiples of ``count``). + :param country: Country code for geographical focus. + :param language: BCP 47 language code (default ``"en"``). + :param safesearch: ``"strict"``, ``"moderate"``, or ``"off"``. + :param livecrawl: ``"web"``, ``"news"``, or ``"all"`` (not allowed on + the free tier). + :param livecrawl_formats: ``["html"]``, ``["markdown"]``, or both. + :param include_domains: Restrict results to these domains (≤ 500). + :param exclude_domains: Exclude these domains (≤ 500). + :param boost_domains: Boost these domains in ranking (≤ 500). + :param crawl_timeout: Max seconds to wait for livecrawl (1–60, default 10). + :param retries: Override the client's retry configuration. + :param server_url: Override the default server URL. + :param timeout_ms: Override the request timeout in milliseconds. + :param http_headers: Additional headers to set or replace. + """ + base_url = server_url if server_url is not None else client._get_url(None, None) + if timeout_ms is None: + timeout_ms = client.sdk_configuration.timeout_ms + + body: dict[str, Any] = dict( + query=query, + count=count, + freshness=freshness, + offset=offset, + country=country, + safesearch=safesearch, + livecrawl=livecrawl, + livecrawl_formats=utils.unmarshal( + livecrawl_formats, Optional[List[models.LiveCrawlFormats]] + ), + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + crawl_timeout=crawl_timeout, + ) + if language is not None: + body["language"] = language + request = models.SearchRequestBody(**body) + + req = client._build_request( + method="POST", + path="/v1/agents/search", + base_url=base_url, + url_variables=None, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=client.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.SearchRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if client.sdk_configuration.retry_config is not UNSET: + retries = client.sdk_configuration.retry_config + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = client.do_request( + hook_ctx=HookContext( + config=client.sdk_configuration, + base_url=base_url or "", + operation_id="agentsSearch", + oauth2_scopes=None, + security_source=get_security_from_env( + client.sdk_configuration.security, models.Security + ), + tags=["search"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + return _handle_response(http_res) + + +async def search_async( + client: You, + *, + query: str, + count: Optional[int] = 10, + freshness: Optional[str] = None, + offset: Optional[int] = None, + country: Optional[str] = None, + language: Optional[str] = None, + safesearch: Optional[str] = None, + livecrawl: Optional[str] = None, + livecrawl_formats: Optional[Iterable[str]] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + crawl_timeout: Optional[int] = 10, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, +) -> models.SearchResponse: + """Async variant of :func:`search`.""" + base_url = server_url if server_url is not None else client._get_url(None, None) + if timeout_ms is None: + timeout_ms = client.sdk_configuration.timeout_ms + + body: dict[str, Any] = dict( + query=query, + count=count, + freshness=freshness, + offset=offset, + country=country, + safesearch=safesearch, + livecrawl=livecrawl, + livecrawl_formats=utils.unmarshal( + livecrawl_formats, Optional[List[models.LiveCrawlFormats]] + ), + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + crawl_timeout=crawl_timeout, + ) + if language is not None: + body["language"] = language + request = models.SearchRequestBody(**body) + + req = client._build_request_async( + method="POST", + path="/v1/agents/search", + base_url=base_url, + url_variables=None, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=client.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.SearchRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if client.sdk_configuration.retry_config is not UNSET: + retries = client.sdk_configuration.retry_config + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await client.do_request_async( + hook_ctx=HookContext( + config=client.sdk_configuration, + base_url=base_url or "", + operation_id="agentsSearch", + oauth2_scopes=None, + security_source=get_security_from_env( + client.sdk_configuration.security, models.Security + ), + tags=["search"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + return await _handle_response_async(http_res) + + +def _handle_response(http_res: Any) -> models.SearchResponse: + """Branch on ``http_res`` status → return ``SearchResponse`` or raise.""" + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.SearchResponse, http_res) + if utils.match_response(http_res, "402", "*"): + text = utils.stream_to_text(http_res) + raise FreeTierLimitError( + text or "Free-tier limit exceeded — set YDC_API_KEY to unlock full features.", + body=text, + ) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, text) + if utils.match_response(http_res, "5XX", "*"): + text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, text) + raise errors.YouDefaultError("Unexpected response received", http_res) + + +async def _handle_response_async(http_res: Any) -> models.SearchResponse: + """Async variant of :func:`_handle_response`.""" + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.SearchResponse, http_res) + if utils.match_response(http_res, "402", "*"): + text = await utils.stream_to_text_async(http_res) + raise FreeTierLimitError( + text or "Free-tier limit exceeded — set YDC_API_KEY to unlock full features.", + body=text, + ) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, text) + if utils.match_response(http_res, "5XX", "*"): + text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, text) + raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py new file mode 100644 index 0000000..144285d --- /dev/null +++ b/tests/test_search_helpers.py @@ -0,0 +1,154 @@ +"""Tests for youdotcom.search_helpers — keyless-capable ``/v1/agents/search``.""" + +import json + +import httpx +import pytest + +from youdotcom import You +from youdotcom.errors import ( + InternalServerErrorResponse, + UnauthorizedResponseError, + UnprocessableEntityResponseError, + YouDefaultError, +) +from youdotcom.models import SearchResponse +from youdotcom.search_helpers import FreeTierLimitError, search, search_async + + +_SEARCH_BODY = json.dumps( + {"results": {"web": [{"title": "Test Result", "url": "https://example.com"}]}} +) + + +def _make_handler(status: int = 200, body: str = _SEARCH_BODY): + def handler(request): + return httpx.Response( + status, headers={"content-type": "application/json"}, content=body + ) + + return handler + + +def _sync_you(handler, *, api_key: str | None = "test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "client": httpx.Client(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +def _async_you(handler, *, api_key: str | None = "test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "async_client": httpx.AsyncClient(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +class TestSearchSuccess: + def test_keyed_search_returns_search_response(self): + res = search(_sync_you(_make_handler(200)), query="python", count=5) + assert isinstance(res, SearchResponse) + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) == 1 + assert res.results.web[0].title == "Test Result" + + def test_keyless_search_returns_search_response(self): + """No api_key_auth → keyless free-tier path still returns SearchResponse.""" + res = search(_sync_you(_make_handler(200), api_key=None), query="python", count=5) + assert isinstance(res, SearchResponse) + + def test_string_enum_params_accepted(self): + """country/safesearch/livecrawl/freshness accept plain strings.""" + res = search( + _sync_you(_make_handler(200)), + query="python", + country="US", + safesearch="strict", + livecrawl="all", + freshness="week", + ) + assert isinstance(res, SearchResponse) + + def test_posts_to_agents_search_endpoint(self): + """Request must hit /v1/agents/search, not /v1/search.""" + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + search(_sync_you(handler), query="python") + assert captured["method"] == "POST" + assert "/v1/agents/search" in captured["url"] + + @pytest.mark.asyncio + async def test_async_keyed_search_returns_search_response(self): + res = await search_async(_async_you(_make_handler(200)), query="python", count=5) + assert isinstance(res, SearchResponse) + + @pytest.mark.asyncio + async def test_async_keyless_search_returns_search_response(self): + res = await search_async( + _async_you(_make_handler(200), api_key=None), query="python", count=5 + ) + assert isinstance(res, SearchResponse) + + +class TestSearchErrors: + def test_402_raises_free_tier_limit_error(self): + body = json.dumps({"error": "count exceeds free tier limit of 50"}) + with pytest.raises(FreeTierLimitError) as exc_info: + search(_sync_you(_make_handler(402, body)), query="python", count=100) + assert exc_info.value.status_code == 402 + assert exc_info.value.body is not None + + def test_401_raises_unauthorized_error(self): + body = json.dumps({"detail": "invalid api key"}) + with pytest.raises(UnauthorizedResponseError): + search(_sync_you(_make_handler(401, body), api_key="bad-key"), query="python") + + def test_422_raises_unprocessable_entity_error(self): + body = json.dumps({"error": "include_domains and exclude_domains are mutually exclusive"}) + with pytest.raises(UnprocessableEntityResponseError): + search(_sync_you(_make_handler(422, body)), query="python") + + def test_500_raises_internal_server_error(self): + body = json.dumps({"detail": "internal server error"}) + with pytest.raises(InternalServerErrorResponse): + search(_sync_you(_make_handler(500, body)), query="python") + + def test_4xx_fallback_raises_default_error(self): + body = json.dumps({"detail": "rate limited"}) + with pytest.raises(YouDefaultError): + search(_sync_you(_make_handler(429, body)), query="python") + + @pytest.mark.asyncio + async def test_async_402_raises_free_tier_limit_error(self): + body = json.dumps({"error": "free tier limit exceeded"}) + with pytest.raises(FreeTierLimitError) as exc_info: + await search_async(_async_you(_make_handler(402, body)), query="python", count=100) + assert exc_info.value.status_code == 402 + + @pytest.mark.asyncio + async def test_async_401_raises_unauthorized_error(self): + body = json.dumps({"detail": "unauthorized"}) + with pytest.raises(UnauthorizedResponseError): + await search_async( + _async_you(_make_handler(401, body), api_key="bad-key"), query="python" + ) + + @pytest.mark.asyncio + async def test_async_500_raises_internal_server_error(self): + body = json.dumps({"detail": "internal server error"}) + with pytest.raises(InternalServerErrorResponse): + await search_async(_async_you(_make_handler(500, body)), query="python") From 0bc50cfb27d36554ea1110a21b3d1b0d96fbf803 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 12:03:02 -0700 Subject: [PATCH 02/35] feat: add Answer API (DX-308) + keyless search host + first-class 402 error (DX-694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer API: - New `Answer` sub-SDK: `you.answer.create()` / `create_async()` → POST /v1/answer - Request model `AnswerRequestBody` reuses existing Country/Language/FreshnessValue enums - Response models: `AnswerResponse` (answer + citations + results.web), `AnswerCitation` (source + excerpts), `AnswerSearchResult` (url + title + snippets + page_age) - Wired into `You` via `_sub_sdk_map` (lazy instantiation, same as Search/Agents/Contents) - 13 unit tests (success, 402, 401, 403, 422, 500, async, param serialization) Keyless search host: - Changed SEARCH_OP_SERVERS, SEARCH_POST_OP_SERVERS, CONTENTS_OP_SERVERS from ydc-index.io → api.you.com (matches MCP server + docs) - search_helpers.py already targeted api.you.com via client._get_url() First-class 402 error: - New `PaymentRequiredResponseError` + `PaymentRequiredResponseErrorData` matching the UpgradeRequiredResponse schema (error, message, upgrade_url, limit, used, period, reset_at) - Replaces standalone `FreeTierLimitError` in search_helpers.py - Reused by both search and answer 402 handlers - Registered in errors/__init__.py (__all__ + _dynamic_imports) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/answer.py | 303 ++++++++++++++++++ src/youdotcom/errors/__init__.py | 8 + .../errors/paymentrequired_response_error.py | 43 +++ src/youdotcom/models/__init__.py | 17 + src/youdotcom/models/answercitation.py | 13 + src/youdotcom/models/answerop.py | 6 + src/youdotcom/models/answerrequestbody.py | 50 +++ src/youdotcom/models/answerresponse.py | 25 ++ src/youdotcom/models/answersearchresult.py | 19 ++ src/youdotcom/models/contentsop.py | 2 +- src/youdotcom/models/searchop.py | 2 +- src/youdotcom/models/searchpostop.py | 2 +- src/youdotcom/sdk.py | 3 + src/youdotcom/search_helpers.py | 34 +- tests/test_answer.py | 185 +++++++++++ tests/test_search_helpers.py | 27 +- 16 files changed, 702 insertions(+), 37 deletions(-) create mode 100644 src/youdotcom/answer.py create mode 100644 src/youdotcom/errors/paymentrequired_response_error.py create mode 100644 src/youdotcom/models/answercitation.py create mode 100644 src/youdotcom/models/answerop.py create mode 100644 src/youdotcom/models/answerrequestbody.py create mode 100644 src/youdotcom/models/answerresponse.py create mode 100644 src/youdotcom/models/answersearchresult.py create mode 100644 tests/test_answer.py diff --git a/src/youdotcom/answer.py b/src/youdotcom/answer.py new file mode 100644 index 0000000..3759358 --- /dev/null +++ b/src/youdotcom/answer.py @@ -0,0 +1,303 @@ +from .basesdk import BaseSDK +from typing import Any, Iterable, List, Mapping, Optional, Union +from youdotcom import errors, models, utils +from youdotcom._hooks import HookContext +from youdotcom.types import OptionalNullable, UNSET +from youdotcom.utils import get_security_from_env +from youdotcom.utils.unmarshal_json_response import unmarshal_json_response + + +class Answer(BaseSDK): + def create( + self, + *, + query: str, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.AnswerResponse: + r"""Returns a synthesized answer with citations from web search results. + + Provide a ``query`` and optional freshness, locale, and domain controls. + The response includes a markdown answer with inline citations, a + citations array with source URLs and supporting excerpts, and the web + results used to generate the answer. + + :param query: The search query used to retrieve relevant web results. + Max 400 characters. Search operators (``site:``, ``OR``, etc.) are + not supported. + :param freshness: Specifies the freshness of the results. One of ``day``, + ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. + :param country: A supported country code that determines the geographical + focus of the web results. + :param language: A supported BCP 47 language tag that determines the + language of the web results. + :param include_domains: Domains to exclusively include. Cannot combine + with ``exclude_domains`` or ``boost_domains``. Max 500. + :param exclude_domains: Domains to exclude. Cannot combine with + ``include_domains``. Can combine with ``boost_domains``. Max 500. + :param boost_domains: Domains to prefer in ranking. Cannot combine with + ``include_domains``. Can combine with ``exclude_domains``. Max 500. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for + this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = models.ANSWER_OP_SERVERS[0] + + request = models.AnswerRequestBody( + query=query, + freshness=freshness, + country=country, + language=language, + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + ) + + req = self._build_request( + method="POST", + path="/v1/answer", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AnswerRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="answer", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["answer"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.AnswerResponse, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + async def create_async( + self, + *, + query: str, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.AnswerResponse: + r"""Returns a synthesized answer with citations from web search results. + + Provide a ``query`` and optional freshness, locale, and domain controls. + The response includes a markdown answer with inline citations, a + citations array with source URLs and supporting excerpts, and the web + results used to generate the answer. + + :param query: The search query used to retrieve relevant web results. + Max 400 characters. Search operators (``site:``, ``OR``, etc.) are + not supported. + :param freshness: Specifies the freshness of the results. One of ``day``, + ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. + :param country: A supported country code that determines the geographical + focus of the web results. + :param language: A supported BCP 47 language tag that determines the + language of the web results. + :param include_domains: Domains to exclusively include. Cannot combine + with ``exclude_domains`` or ``boost_domains``. Max 500. + :param exclude_domains: Domains to exclude. Cannot combine with + ``include_domains``. Can combine with ``boost_domains``. Max 500. + :param boost_domains: Domains to prefer in ranking. Cannot combine with + ``include_domains``. Can combine with ``exclude_domains``. Max 500. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for + this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = models.ANSWER_OP_SERVERS[0] + + request = models.AnswerRequestBody( + query=query, + freshness=freshness, + country=country, + language=language, + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + ) + + req = self._build_request_async( + method="POST", + path="/v1/answer", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AnswerRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="answer", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["answer"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.AnswerResponse, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/src/youdotcom/errors/__init__.py b/src/youdotcom/errors/__init__.py index aea2e79..870392d 100644 --- a/src/youdotcom/errors/__init__.py +++ b/src/youdotcom/errors/__init__.py @@ -55,6 +55,10 @@ InternalServerErrorResponseData, ) from .no_response_error import NoResponseError + from .paymentrequired_response_error import ( + PaymentRequiredResponseError, + PaymentRequiredResponseErrorData, + ) from .researchop import ( ResearchForbiddenError, ResearchForbiddenErrorData, @@ -120,6 +124,8 @@ "InternalServerErrorResponse", "InternalServerErrorResponseData", "NoResponseError", + "PaymentRequiredResponseError", + "PaymentRequiredResponseErrorData", "ResearchForbiddenError", "ResearchForbiddenErrorData", "ResearchInternalServerError", @@ -179,6 +185,8 @@ "InternalServerErrorResponse": ".internalservererror_response", "InternalServerErrorResponseData": ".internalservererror_response", "NoResponseError": ".no_response_error", + "PaymentRequiredResponseError": ".paymentrequired_response_error", + "PaymentRequiredResponseErrorData": ".paymentrequired_response_error", "ResearchForbiddenError": ".researchop", "ResearchForbiddenErrorData": ".researchop", "ResearchInternalServerError": ".researchop", diff --git a/src/youdotcom/errors/paymentrequired_response_error.py b/src/youdotcom/errors/paymentrequired_response_error.py new file mode 100644 index 0000000..e460f65 --- /dev/null +++ b/src/youdotcom/errors/paymentrequired_response_error.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from dataclasses import dataclass, field +import httpx +from typing import Optional +from youdotcom.errors import YouError +from youdotcom.types import BaseModel + + +class PaymentRequiredResponseErrorData(BaseModel): + r"""Body of a 402 ``UpgradeRequiredResponse`` — returned when the account + cannot make paid API requests (free-tier limit exceeded, insufficient credits). + """ + error: Optional[str] = None + r"""The error code (e.g. ``"payment_required"``).""" + message: Optional[str] = None + r"""A human-readable description of the error.""" + upgrade_url: Optional[str] = None + r"""URL for adding credits or upgrading the account.""" + limit: Optional[int] = None + r"""The usage limit, when available.""" + used: Optional[int] = None + r"""The usage consumed, when available.""" + period: Optional[str] = None + r"""The usage period, when available.""" + reset_at: Optional[str] = None + r"""The reset timestamp, when available.""" + + +@dataclass(unsafe_hash=True) +class PaymentRequiredResponseError(YouError): + r"""Payment Required (402). The account cannot make paid API requests.""" + + data: PaymentRequiredResponseErrorData = field(hash=False) + + def __init__( + self, + data: PaymentRequiredResponseErrorData, + raw_response: httpx.Response, + body: Optional[str] = None, + ): + message = body or raw_response.text + super().__init__(message, raw_response, body) + object.__setattr__(self, "data", data) diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index ad650a3..d1f1632 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -13,6 +13,11 @@ WorkflowConfig, WorkflowConfigTypedDict, ) + from .answercitation import AnswerCitation + from .answerop import ANSWER_OP_SERVERS + from .answerrequestbody import AnswerRequestBody + from .answerresponse import AnswerResponse, AnswerResults + from .answersearchresult import AnswerSearchResult from .agentruns422response_error import Detail, DetailTypedDict, Loc, LocTypedDict from .agentrunsbatchresponse import ( AgentRunsBatchResponse, @@ -197,6 +202,12 @@ "AGENTS_RUNS_OP_SERVERS", "AdvancedAgentRunsRequest", "AdvancedAgentRunsRequestTypedDict", + "ANSWER_OP_SERVERS", + "AnswerCitation", + "AnswerRequestBody", + "AnswerResponse", + "AnswerResults", + "AnswerSearchResult", "AgentRunsBatchResponse", "AgentRunsBatchResponseTypedDict", "AgentRunsResponseOutput", @@ -366,6 +377,12 @@ "ToolTypedDict": ".advancedagentrunsrequest", "WorkflowConfig": ".advancedagentrunsrequest", "WorkflowConfigTypedDict": ".advancedagentrunsrequest", + "ANSWER_OP_SERVERS": ".answerop", + "AnswerCitation": ".answercitation", + "AnswerRequestBody": ".answerrequestbody", + "AnswerResponse": ".answerresponse", + "AnswerResults": ".answerresponse", + "AnswerSearchResult": ".answersearchresult", "Detail": ".agentruns422response_error", "DetailTypedDict": ".agentruns422response_error", "Loc": ".agentruns422response_error", diff --git a/src/youdotcom/models/answercitation.py b/src/youdotcom/models/answercitation.py new file mode 100644 index 0000000..e781c8a --- /dev/null +++ b/src/youdotcom/models/answercitation.py @@ -0,0 +1,13 @@ +from __future__ import annotations +from typing import List +from youdotcom.types import BaseModel + + +class AnswerCitation(BaseModel): + r"""A source cited in the answer, with supporting excerpts.""" + + source: str + r"""The URL of the cited source.""" + + excerpts: List[str] = [] + r"""Verbatim excerpts from the cited source that support the answer.""" diff --git a/src/youdotcom/models/answerop.py b/src/youdotcom/models/answerop.py new file mode 100644 index 0000000..d8e71e4 --- /dev/null +++ b/src/youdotcom/models/answerop.py @@ -0,0 +1,6 @@ +from __future__ import annotations + + +ANSWER_OP_SERVERS = [ + "https://api.you.com", +] diff --git a/src/youdotcom/models/answerrequestbody.py b/src/youdotcom/models/answerrequestbody.py new file mode 100644 index 0000000..90a16c6 --- /dev/null +++ b/src/youdotcom/models/answerrequestbody.py @@ -0,0 +1,50 @@ +from __future__ import annotations +from .country import Country +from .freshnessvalue import FreshnessValue +from .language import Language +from pydantic import model_serializer +from typing import List, Optional +from youdotcom.types import BaseModel, UNSET_SENTINEL + + +class AnswerRequestBody(BaseModel): + r"""Request body for ``POST /v1/answer``.""" + + query: str + r"""The search query used to retrieve relevant web results. Max 400 characters. Search operators (``site:``, ``OR``, etc.) are not supported.""" + + freshness: Optional[FreshnessValue] = None + r"""Specifies the freshness of the results. One of ``day``, ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``.""" + + country: Optional[Country] = None + r"""A supported country code that determines the geographical focus of the web results.""" + + language: Optional[Language] = None + r"""A supported BCP 47 language tag that determines the language of the web results.""" + + include_domains: Optional[List[str]] = None + r"""Domains to exclusively include. Cannot combine with ``exclude_domains`` or ``boost_domains``. Max 500.""" + + exclude_domains: Optional[List[str]] = None + r"""Domains to exclude. Cannot combine with ``include_domains``. Can combine with ``boost_domains``. Max 500.""" + + boost_domains: Optional[List[str]] = None + r"""Domains to prefer in ranking. Cannot combine with ``include_domains``. Can combine with ``exclude_domains``. Max 500.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set( + ["freshness", "country", "language", "include_domains", "exclude_domains", "boost_domains"] + ) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m diff --git a/src/youdotcom/models/answerresponse.py b/src/youdotcom/models/answerresponse.py new file mode 100644 index 0000000..9c1498b --- /dev/null +++ b/src/youdotcom/models/answerresponse.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from .answercitation import AnswerCitation +from .answersearchresult import AnswerSearchResult +from typing import List, Optional +from youdotcom.types import BaseModel + + +class AnswerResults(BaseModel): + r"""Search results grouped by result type.""" + + web: List[AnswerSearchResult] = [] + r"""All web search results considered during answer synthesis.""" + + +class AnswerResponse(BaseModel): + r"""A synthesized answer with citations and supporting search results.""" + + answer: str + r"""The synthesized response with numbered inline citations that reference items in the ``citations`` array.""" + + citations: List[AnswerCitation] = [] + r"""The sources cited in the answer, in citation order.""" + + results: AnswerResults = AnswerResults() + r"""Search results grouped by result type.""" diff --git a/src/youdotcom/models/answersearchresult.py b/src/youdotcom/models/answersearchresult.py new file mode 100644 index 0000000..adc5d47 --- /dev/null +++ b/src/youdotcom/models/answersearchresult.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from typing import List, Optional +from youdotcom.types import BaseModel + + +class AnswerSearchResult(BaseModel): + r"""A web search result used during answer synthesis.""" + + url: str + r"""The URL of the source webpage.""" + + title: str + r"""The title of the source webpage.""" + + snippets: List[str] = [] + r"""Text snippets from the search result that preview its content.""" + + page_age: Optional[str] = None + r"""The publication date or age supplied by the search result.""" diff --git a/src/youdotcom/models/contentsop.py b/src/youdotcom/models/contentsop.py index 9e10292..b3ff6a2 100644 --- a/src/youdotcom/models/contentsop.py +++ b/src/youdotcom/models/contentsop.py @@ -10,7 +10,7 @@ CONTENTS_OP_SERVERS = [ - "https://ydc-index.io", + "https://api.you.com", ] diff --git a/src/youdotcom/models/searchop.py b/src/youdotcom/models/searchop.py index 358a4fa..fe588ea 100644 --- a/src/youdotcom/models/searchop.py +++ b/src/youdotcom/models/searchop.py @@ -15,7 +15,7 @@ SEARCH_OP_SERVERS = [ - "https://ydc-index.io", + "https://api.you.com", ] diff --git a/src/youdotcom/models/searchpostop.py b/src/youdotcom/models/searchpostop.py index e0f8b5b..0b91952 100644 --- a/src/youdotcom/models/searchpostop.py +++ b/src/youdotcom/models/searchpostop.py @@ -4,5 +4,5 @@ SEARCH_POST_OP_SERVERS = [ - "https://ydc-index.io", + "https://api.you.com", ] diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 166fce4..8acc5ae 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -29,6 +29,7 @@ if TYPE_CHECKING: from youdotcom.agents import Agents + from youdotcom.answer import Answer from youdotcom.contents_sdk import ContentsSDK from youdotcom.search import Search @@ -48,10 +49,12 @@ class You(BaseSDK): """ agents: "Agents" + answer: "Answer" search: "Search" contents: "ContentsSDK" _sub_sdk_map = { "agents": ("youdotcom.agents", "Agents"), + "answer": ("youdotcom.answer", "Answer"), "search": ("youdotcom.search", "Search"), "contents": ("youdotcom.contents_sdk", "ContentsSDK"), } diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py index 613085a..105ee02 100644 --- a/src/youdotcom/search_helpers.py +++ b/src/youdotcom/search_helpers.py @@ -28,22 +28,6 @@ from youdotcom.utils.unmarshal_json_response import unmarshal_json_response -class FreeTierLimitError(Exception): - """Raised when the keyless free-tier search endpoint returns HTTP 402. - - Carries the raw response ``body`` so callers can surface the upgrade - message / URL. - """ - - status_code: int - body: Optional[str] - - def __init__(self, message: str, *, body: Optional[str] = None) -> None: - super().__init__(message) - self.status_code = 402 - self.body = body - - def search( client: You, *, @@ -265,12 +249,11 @@ def _handle_response(http_res: Any) -> models.SearchResponse: response_data: Any = None if utils.match_response(http_res, "200", "application/json"): return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "402", "*"): - text = utils.stream_to_text(http_res) - raise FreeTierLimitError( - text or "Free-tier limit exceeded — set YDC_API_KEY to unlock full features.", - body=text, + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res ) + raise errors.PaymentRequiredResponseError(response_data, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.UnauthorizedResponseErrorData, http_res @@ -305,12 +288,11 @@ async def _handle_response_async(http_res: Any) -> models.SearchResponse: response_data: Any = None if utils.match_response(http_res, "200", "application/json"): return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "402", "*"): - text = await utils.stream_to_text_async(http_res) - raise FreeTierLimitError( - text or "Free-tier limit exceeded — set YDC_API_KEY to unlock full features.", - body=text, + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res ) + raise errors.PaymentRequiredResponseError(response_data, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.UnauthorizedResponseErrorData, http_res diff --git a/tests/test_answer.py b/tests/test_answer.py new file mode 100644 index 0000000..926fdbc --- /dev/null +++ b/tests/test_answer.py @@ -0,0 +1,185 @@ +"""Tests for youdotcom.answer — POST /v1/answer.""" + +import json + +import httpx +import pytest + +from youdotcom import You +from youdotcom.errors import ( + ForbiddenResponseError, + InternalServerErrorResponse, + PaymentRequiredResponseError, + UnauthorizedResponseError, + UnprocessableEntityResponseError, + YouDefaultError, +) +from youdotcom.models import AnswerResponse + +_ANSWER_BODY = json.dumps( + { + "answer": "Quantum computing advanced in 2025[[1]].", + "citations": [ + {"source": "https://example.com/quantum", "excerpts": ["IBM announced a new processor."]} + ], + "results": { + "web": [ + {"url": "https://example.com/quantum", "title": "Quantum News", "snippets": ["IBM announced a new processor."]} + ] + }, + } +) + + +def _make_handler(status: int = 200, body: str = _ANSWER_BODY): + def handler(request): + return httpx.Response( + status, headers={"content-type": "application/json"}, content=body + ) + + return handler + + +def _sync_you(handler, *, api_key: str | None = "test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "client": httpx.Client(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +def _async_you(handler, *, api_key: str | None = "test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "async_client": httpx.AsyncClient(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +class TestAnswerSuccess: + def test_returns_answer_response(self): + res = _sync_you(_make_handler(200)).answer.create(query="quantum computing 2025") + assert isinstance(res, AnswerResponse) + assert "Quantum computing" in res.answer + assert len(res.citations) == 1 + assert res.citations[0].source == "https://example.com/quantum" + assert len(res.citations[0].excerpts) == 1 + assert len(res.results.web) == 1 + assert res.results.web[0].title == "Quantum News" + + def test_posts_to_answer_endpoint(self): + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + captured["method"] = request.method + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + _sync_you(handler).answer.create(query="test") + assert captured["method"] == "POST" + assert "/v1/answer" in captured["url"] + + def test_domain_params_serialized(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + _sync_you(handler).answer.create( + query="test", + include_domains=["nature.com", "science.org"], + country="US", + language="EN", + freshness="week", + ) + assert captured["body"]["include_domains"] == ["nature.com", "science.org"] + assert captured["body"]["country"] == "US" + assert captured["body"]["freshness"] == "week" + + def test_omits_optional_params_when_not_set(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + _sync_you(handler).answer.create(query="test") + body = captured["body"] + assert "freshness" not in body + assert "country" not in body + assert "include_domains" not in body + assert body["query"] == "test" + + @pytest.mark.asyncio + async def test_async_returns_answer_response(self): + res = await _async_you(_make_handler(200)).answer.create_async(query="quantum") + assert isinstance(res, AnswerResponse) + assert len(res.citations) == 1 + + +class TestAnswerErrors: + def test_402_raises_payment_required_error(self): + body = json.dumps({ + "error": "payment_required", + "message": "Insufficient credits", + "upgrade_url": "https://you.com/platform", + }) + with pytest.raises(PaymentRequiredResponseError) as exc_info: + _sync_you(_make_handler(402, body)).answer.create(query="test") + assert exc_info.value.status_code == 402 + assert exc_info.value.data.message == "Insufficient credits" + assert exc_info.value.data.upgrade_url == "https://you.com/platform" + + def test_401_raises_unauthorized_error(self): + body = json.dumps({"detail": "Invalid or expired API key"}) + with pytest.raises(UnauthorizedResponseError): + _sync_you(_make_handler(401, body), api_key="bad-key").answer.create(query="test") + + def test_403_raises_forbidden_error(self): + body = json.dumps({"detail": "Missing required scopes"}) + with pytest.raises(ForbiddenResponseError): + _sync_you(_make_handler(403, body)).answer.create(query="test") + + def test_422_raises_unprocessable_entity_error(self): + body = json.dumps({"detail": [{"type": "missing", "loc": ["body", "query"], "msg": "Field required"}]}) + with pytest.raises(UnprocessableEntityResponseError): + _sync_you(_make_handler(422, body)).answer.create(query="") + + def test_500_raises_internal_server_error(self): + body = json.dumps({"detail": "Internal server error"}) + with pytest.raises(InternalServerErrorResponse): + _sync_you(_make_handler(500, body)).answer.create(query="test") + + def test_4xx_fallback_raises_default_error(self): + body = json.dumps({"detail": "rate limited"}) + with pytest.raises(YouDefaultError): + _sync_you(_make_handler(429, body)).answer.create(query="test") + + @pytest.mark.asyncio + async def test_async_402_raises_payment_required_error(self): + body = json.dumps({ + "error": "payment_required", + "message": "Insufficient credits", + "upgrade_url": "https://you.com/platform", + }) + with pytest.raises(PaymentRequiredResponseError) as exc_info: + await _async_you(_make_handler(402, body)).answer.create_async(query="test") + assert exc_info.value.status_code == 402 + assert exc_info.value.data.error == "payment_required" + + @pytest.mark.asyncio + async def test_async_500_raises_internal_server_error(self): + body = json.dumps({"detail": "internal server error"}) + with pytest.raises(InternalServerErrorResponse): + await _async_you(_make_handler(500, body)).answer.create_async(query="test") diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py index 144285d..e83b172 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_helpers.py @@ -8,12 +8,13 @@ from youdotcom import You from youdotcom.errors import ( InternalServerErrorResponse, + PaymentRequiredResponseError, UnauthorizedResponseError, UnprocessableEntityResponseError, YouDefaultError, ) from youdotcom.models import SearchResponse -from youdotcom.search_helpers import FreeTierLimitError, search, search_async +from youdotcom.search_helpers import search, search_async _SEARCH_BODY = json.dumps( @@ -105,12 +106,17 @@ async def test_async_keyless_search_returns_search_response(self): class TestSearchErrors: - def test_402_raises_free_tier_limit_error(self): - body = json.dumps({"error": "count exceeds free tier limit of 50"}) - with pytest.raises(FreeTierLimitError) as exc_info: + def test_402_raises_payment_required_error(self): + body = json.dumps({ + "error": "payment_required", + "message": "Insufficient credits", + "upgrade_url": "https://you.com/platform", + }) + with pytest.raises(PaymentRequiredResponseError) as exc_info: search(_sync_you(_make_handler(402, body)), query="python", count=100) assert exc_info.value.status_code == 402 - assert exc_info.value.body is not None + assert exc_info.value.data.message == "Insufficient credits" + assert exc_info.value.data.upgrade_url == "https://you.com/platform" def test_401_raises_unauthorized_error(self): body = json.dumps({"detail": "invalid api key"}) @@ -133,11 +139,16 @@ def test_4xx_fallback_raises_default_error(self): search(_sync_you(_make_handler(429, body)), query="python") @pytest.mark.asyncio - async def test_async_402_raises_free_tier_limit_error(self): - body = json.dumps({"error": "free tier limit exceeded"}) - with pytest.raises(FreeTierLimitError) as exc_info: + async def test_async_402_raises_payment_required_error(self): + body = json.dumps({ + "error": "payment_required", + "message": "Free tier limit exceeded", + "upgrade_url": "https://you.com/platform", + }) + with pytest.raises(PaymentRequiredResponseError) as exc_info: await search_async(_async_you(_make_handler(402, body)), query="python", count=100) assert exc_info.value.status_code == 402 + assert exc_info.value.data.error == "payment_required" @pytest.mark.asyncio async def test_async_401_raises_unauthorized_error(self): From 9124c36e588c08ff4c7b24a43686d6ed75e121fd Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 12:21:55 -0700 Subject: [PATCH 03/35] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Normalize lowercase language strings (e.g. "en" → "EN") before pydantic validation in search() and search_async() (P1) - Use self._get_url(None, None) for default base_url in Answer.create() and create_async() so You(server_url=...) is honored (P1) - Fix search() docstring: FreeTierLimitError → PaymentRequiredResponseError (P3) - Add test_lowercase_language_is_normalized Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/answer.py | 4 ++-- src/youdotcom/search_helpers.py | 6 +++--- tests/test_search_helpers.py | 5 +++++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/youdotcom/answer.py b/src/youdotcom/answer.py index 3759358..428a73d 100644 --- a/src/youdotcom/answer.py +++ b/src/youdotcom/answer.py @@ -61,7 +61,7 @@ def create( if server_url is not None: base_url = server_url else: - base_url = models.ANSWER_OP_SERVERS[0] + base_url = self._get_url(None, None) request = models.AnswerRequestBody( query=query, @@ -208,7 +208,7 @@ async def create_async( if server_url is not None: base_url = server_url else: - base_url = models.ANSWER_OP_SERVERS[0] + base_url = self._get_url(None, None) request = models.AnswerRequestBody( query=query, diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py index 105ee02..06146a8 100644 --- a/src/youdotcom/search_helpers.py +++ b/src/youdotcom/search_helpers.py @@ -53,7 +53,7 @@ def search( With no API key configured on ``client``, runs in the free tier (count ≤ 50, no livecrawl). A ``402`` response raises - :class:`FreeTierLimitError` carrying the upgrade message. + :class:`~youdotcom.errors.PaymentRequiredResponseError` carrying the upgrade message. Enum-typed parameters (``country``, ``safesearch``, ``livecrawl``, ``freshness``) accept plain strings — pydantic coerces them when building @@ -101,7 +101,7 @@ def search( crawl_timeout=crawl_timeout, ) if language is not None: - body["language"] = language + body["language"] = language.upper() if isinstance(language, str) else language request = models.SearchRequestBody(**body) req = client._build_request( @@ -194,7 +194,7 @@ async def search_async( crawl_timeout=crawl_timeout, ) if language is not None: - body["language"] = language + body["language"] = language.upper() if isinstance(language, str) else language request = models.SearchRequestBody(**body) req = client._build_request_async( diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py index e83b172..8e8f102 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_helpers.py @@ -77,6 +77,11 @@ def test_string_enum_params_accepted(self): ) assert isinstance(res, SearchResponse) + def test_lowercase_language_is_normalized(self): + """language='en' should be normalized to 'EN' before model validation.""" + res = search(_sync_you(_make_handler(200)), query="python", language="en") + assert isinstance(res, SearchResponse) + def test_posts_to_agents_search_endpoint(self): """Request must hit /v1/agents/search, not /v1/search.""" captured: dict = {} From c8c881dd873b09abcf473f3531a3fc29bd14258f Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 12:56:13 -0700 Subject: [PATCH 04/35] test: expand unit + live test coverage for answer API and search helpers Unit tests added: - Answer: exclude/boost domains serialization, server_url override honored, 402 with optional usage fields (limit/used/period/reset_at), page_age deserialization, multi-citation response - Search helpers: exclude/boost domains accepted, lowercase language normalized Live tests added (test_live.py): - TestLiveAnswer: basic answer, freshness, country, boost_domains, async - TestLiveSearchHelpers: keyed search, filters, domain filters, async - All 9 live tests pass against real API with YDC_API_KEY 32 unit tests pass, 9 live tests pass, mypy clean. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/test_answer.py | 74 ++++++++++++++++++-- tests/test_live.py | 132 +++++++++++++++++++++++++++++++++++ tests/test_search_helpers.py | 10 +++ 3 files changed, 210 insertions(+), 6 deletions(-) diff --git a/tests/test_answer.py b/tests/test_answer.py index 926fdbc..078dd4f 100644 --- a/tests/test_answer.py +++ b/tests/test_answer.py @@ -18,13 +18,15 @@ _ANSWER_BODY = json.dumps( { - "answer": "Quantum computing advanced in 2025[[1]].", + "answer": "Quantum computing advanced in 2025[[1, 2]].", "citations": [ - {"source": "https://example.com/quantum", "excerpts": ["IBM announced a new processor."]} + {"source": "https://example.com/quantum", "excerpts": ["IBM announced a new processor."]}, + {"source": "https://example.com/ibm", "excerpts": ["Google achieved error correction.", "IBM unveiled 1000 qubits."]}, ], "results": { "web": [ - {"url": "https://example.com/quantum", "title": "Quantum News", "snippets": ["IBM announced a new processor."]} + {"url": "https://example.com/quantum", "title": "Quantum News", "snippets": ["IBM announced a new processor."], "page_age": "2025-06-25T11:41:00"}, + {"url": "https://example.com/ibm", "title": "IBM Quantum", "snippets": ["Google achieved error correction."]}, ] }, } @@ -65,11 +67,15 @@ def test_returns_answer_response(self): res = _sync_you(_make_handler(200)).answer.create(query="quantum computing 2025") assert isinstance(res, AnswerResponse) assert "Quantum computing" in res.answer - assert len(res.citations) == 1 + assert len(res.citations) == 2 assert res.citations[0].source == "https://example.com/quantum" assert len(res.citations[0].excerpts) == 1 - assert len(res.results.web) == 1 + assert res.citations[1].source == "https://example.com/ibm" + assert len(res.citations[1].excerpts) == 2 + assert len(res.results.web) == 2 assert res.results.web[0].title == "Quantum News" + assert res.results.web[0].page_age == "2025-06-25T11:41:00" + assert res.results.web[1].page_age is None def test_posts_to_answer_endpoint(self): captured: dict = {} @@ -105,6 +111,43 @@ def handler(request): assert captured["body"]["country"] == "US" assert captured["body"]["freshness"] == "week" + def test_exclude_and_boost_domains_serialized(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + _sync_you(handler).answer.create( + query="test", + exclude_domains=["spam.com"], + boost_domains=["reuters.com"], + ) + assert captured["body"]["exclude_domains"] == ["spam.com"] + assert captured["body"]["boost_domains"] == ["reuters.com"] + assert "include_domains" not in captured["body"] + + def test_server_url_override_honored(self): + """You(server_url=...) should be respected by answer.create().""" + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + you = You( + server_url="http://custom.local", + client=httpx.Client(transport=httpx.MockTransport(handler)), + api_key_auth="test-key", + ) + you.answer.create(query="test") + assert "http://custom.local" in captured["url"] + assert "/v1/answer" in captured["url"] + def test_omits_optional_params_when_not_set(self): captured: dict = {} @@ -125,7 +168,8 @@ def handler(request): async def test_async_returns_answer_response(self): res = await _async_you(_make_handler(200)).answer.create_async(query="quantum") assert isinstance(res, AnswerResponse) - assert len(res.citations) == 1 + assert len(res.citations) == 2 + assert len(res.results.web) == 2 class TestAnswerErrors: @@ -141,6 +185,24 @@ def test_402_raises_payment_required_error(self): assert exc_info.value.data.message == "Insufficient credits" assert exc_info.value.data.upgrade_url == "https://you.com/platform" + def test_402_with_usage_fields(self): + """402 response with optional limit/used/period/reset_at fields.""" + body = json.dumps({ + "error": "payment_required", + "message": "Daily limit exceeded", + "upgrade_url": "https://you.com/platform", + "limit": 100, + "used": 100, + "period": "day", + "reset_at": "2026-08-05T00:00:00Z", + }) + with pytest.raises(PaymentRequiredResponseError) as exc_info: + _sync_you(_make_handler(402, body)).answer.create(query="test") + assert exc_info.value.data.limit == 100 + assert exc_info.value.data.used == 100 + assert exc_info.value.data.period == "day" + assert exc_info.value.data.reset_at == "2026-08-05T00:00:00Z" + def test_401_raises_unauthorized_error(self): body = json.dumps({"detail": "Invalid or expired API key"}) with pytest.raises(UnauthorizedResponseError): diff --git a/tests/test_live.py b/tests/test_live.py index ebe9304..050a26d 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -37,7 +37,9 @@ TaskResponse, TaskDetail, FinanceResearchEffort, + AnswerResponse, ) +from youdotcom.search_helpers import search as search_helper, search_async as search_helper_async from youdotcom.research_helpers import ( research_background, poll_research_task, @@ -708,6 +710,136 @@ def test_frontier_without_background_raises_422(self, you_client): ) +# --------------------------------------------------------------------------- +# Answer API (new in 2.6.0) +# --------------------------------------------------------------------------- +class TestLiveAnswer: + """Live tests for the Answer API (POST /v1/answer). + + The Answer API returns a synthesized answer with citations and web results. + Requires an API key (not keyless). + """ + + def test_basic_answer(self, you_client): + """Test basic answer query returns AnswerResponse with answer + citations.""" + with you_client as you: + res = you.answer.create(query="What is the capital of France?") + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + # Citations should be present for a factual query + assert len(res.citations) > 0 + for citation in res.citations: + assert citation.source is not None + assert len(citation.source) > 0 + # Web results should be present + assert len(res.results.web) > 0 + for result in res.results.web: + assert result.url is not None + assert result.title is not None + + def test_answer_with_freshness(self, you_client): + """Test answer with freshness filter.""" + with you_client as you: + res = you.answer.create( + query="Latest AI developments", + freshness="week", + ) + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + + def test_answer_with_country(self, you_client): + """Test answer with country filter.""" + with you_client as you: + res = you.answer.create( + query="Best restaurants in London", + country=Country.GB, + ) + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + + def test_answer_with_boost_domains(self, you_client): + """Test answer with boost_domains (can combine with exclude, not include).""" + with you_client as you: + res = you.answer.create( + query="Python type hints", + boost_domains=["python.org", "docs.python.org"], + ) + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + + @pytest.mark.asyncio + async def test_async_answer(self, you_client): + """Test async answer.create_async().""" + with you_client as you: + res = await you.answer.create_async(query="What is 2+2?") + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + + +# --------------------------------------------------------------------------- +# Search helpers (keyless-capable /v1/agents/search) +# --------------------------------------------------------------------------- +class TestLiveSearchHelpers: + """Live tests for search_helpers.search() → POST /v1/agents/search. + + These verify the keyless-capable search helper against the real API. + With an API key, the proxy forwards to /v1/search with full features. + """ + + def test_keyed_search_helper(self, you_client): + """search() with an API key returns full SearchResponse.""" + with you_client as you: + res = search_helper(you, query="Python programming language", count=5) + + assert isinstance(res, type(res.results)) or res.results is not None + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) > 0 + + def test_search_helper_with_filters(self, you_client): + """search() with country/freshness/safesearch filters.""" + with you_client as you: + res = search_helper( + you, + query="artificial intelligence news", + count=3, + country="US", + freshness="week", + safesearch="moderate", + ) + + assert res.results is not None + assert res.results.web is not None + + def test_search_helper_with_domain_filters(self, you_client): + """search() with include_domains / exclude_domains / boost_domains.""" + with you_client as you: + res = search_helper( + you, + query="Python type hints", + count=3, + boost_domains=["python.org"], + exclude_domains=["spam-site.com"], + ) + + assert res.results is not None + + @pytest.mark.asyncio + async def test_async_search_helper(self, you_client): + """search_async() returns SearchResponse.""" + with you_client as you: + res = await search_helper_async(you, query="What is machine learning?", count=3) + + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) > 0 + + if __name__ == "__main__": # Run with: python -m pytest tests/test_live.py -v pytest.main([__file__, "-v"]) diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py index 8e8f102..a981895 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_helpers.py @@ -82,6 +82,16 @@ def test_lowercase_language_is_normalized(self): res = search(_sync_you(_make_handler(200)), query="python", language="en") assert isinstance(res, SearchResponse) + def test_exclude_and_boost_domains_accepted(self): + """exclude_domains and boost_domains should be accepted as lists.""" + res = search( + _sync_you(_make_handler(200)), + query="python", + exclude_domains=["spam.com"], + boost_domains=["realpython.com"], + ) + assert isinstance(res, SearchResponse) + def test_posts_to_agents_search_endpoint(self): """Request must hit /v1/agents/search, not /v1/search.""" captured: dict = {} From 63aceac59eb6c44211ac6838f8e81d9f523d4efa Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 12:59:45 -0700 Subject: [PATCH 05/35] test: add keyless live tests for search helpers - test_keyless_search_helper: no API key, hits /v1/agents/search free tier - test_keyless_search_helper_with_filters: keyless + country/freshness/safesearch - test_async_keyless_search_helper: async keyless - All 3 pass against real api.you.com proxy with no auth Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/test_live.py | 39 +++++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/tests/test_live.py b/tests/test_live.py index 050a26d..0d02268 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -16,6 +16,7 @@ """ import os +import httpx import pytest from youdotcom import You @@ -796,14 +797,24 @@ def test_keyed_search_helper(self, you_client): with you_client as you: res = search_helper(you, query="Python programming language", count=5) - assert isinstance(res, type(res.results)) or res.results is not None assert res.results is not None assert res.results.web is not None assert len(res.results.web) > 0 - def test_search_helper_with_filters(self, you_client): - """search() with country/freshness/safesearch filters.""" - with you_client as you: + def test_keyless_search_helper(self): + """search() with NO API key works via the free-tier proxy.""" + you = You(timeout_ms=LIVE_TIMEOUT_MS) + with you: + res = search_helper(you, query="Python programming language", count=5) + + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) > 0 + + def test_keyless_search_helper_with_filters(self): + """Keyless search accepts country/freshness/safesearch (server enforces limits).""" + you = You(timeout_ms=LIVE_TIMEOUT_MS) + with you: res = search_helper( you, query="artificial intelligence news", @@ -816,23 +827,11 @@ def test_search_helper_with_filters(self, you_client): assert res.results is not None assert res.results.web is not None - def test_search_helper_with_domain_filters(self, you_client): - """search() with include_domains / exclude_domains / boost_domains.""" - with you_client as you: - res = search_helper( - you, - query="Python type hints", - count=3, - boost_domains=["python.org"], - exclude_domains=["spam-site.com"], - ) - - assert res.results is not None - @pytest.mark.asyncio - async def test_async_search_helper(self, you_client): - """search_async() returns SearchResponse.""" - with you_client as you: + async def test_async_keyless_search_helper(self): + """search_async() with NO API key works via the free-tier proxy.""" + you = You(async_client=httpx.AsyncClient(), timeout_ms=LIVE_TIMEOUT_MS) + with you: res = await search_helper_async(you, query="What is machine learning?", count=3) assert res.results is not None From 5cd1abefcbcf5d07b7208022f55e3f40c0d7ba12 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 13:27:19 -0700 Subject: [PATCH 06/35] =?UTF-8?q?fix:=20drift=20check=20fixes=20=E2=80=94?= =?UTF-8?q?=20answer=20language=20normalization=20+=20422/500=20error=20sh?= =?UTF-8?q?apes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer API language/country normalization: - answer.create() and create_async() now accept Optional[str] for country and language (was Optional[models.Country/Language] enum) - Normalizes to uppercase before passing to AnswerRequestBody so callers can pass "en", "us" etc. without pydantic validation errors - Matches search_helpers.py behavior 422 error data model (UnprocessableEntityResponseErrorData): - Added `detail: Optional[List[dict]]` for FastAPI validation errors ({detail: [{type, loc, msg, input, ctx}]}) - Added `errors: Optional[List[dict]]` for JSON:API format ({errors: [{status, code, title, detail}]}) - Existing `error: Optional[str]` preserved for search spec format ({error: "..."}) — backward compatible - All three 422 body shapes now deserialize without crashing 500 error data model (InternalServerErrorResponseData): - Added `errors: Optional[List[dict]]` for JSON:API format ({errors: [{status, code, title}]}) - Existing `detail: Optional[str]` preserved — backward compatible Tests: - test_422_json_api_format, test_422_search_spec_format, test_500_with_json_api_errors: verify all error shapes deserialize - test_lowercase_language_and_country_normalized: verify "en"/"us" → "EN"/"US" - 35 unit tests pass, 5 live tests pass, mypy clean Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/answer.py | 22 +++++----- .../errors/internalservererror_response.py | 16 ++++++- .../unprocessableentity_response_error.py | 23 +++++++++- tests/test_answer.py | 43 +++++++++++++++++-- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/youdotcom/answer.py b/src/youdotcom/answer.py index 428a73d..09c605b 100644 --- a/src/youdotcom/answer.py +++ b/src/youdotcom/answer.py @@ -15,8 +15,8 @@ def create( freshness: Optional[ Union[models.FreshnessValue, models.FreshnessValueTypedDict] ] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = None, + country: Optional[str] = None, + language: Optional[str] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -63,15 +63,16 @@ def create( else: base_url = self._get_url(None, None) - request = models.AnswerRequestBody( + body: dict = dict( query=query, freshness=freshness, - country=country, - language=language, + country=country.upper() if isinstance(country, str) else country, + language=language.upper() if isinstance(language, str) else language, include_domains=utils.unmarshal(include_domains, Optional[List[str]]), exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), ) + request = models.AnswerRequestBody(**body) req = self._build_request( method="POST", @@ -162,8 +163,8 @@ async def create_async( freshness: Optional[ Union[models.FreshnessValue, models.FreshnessValueTypedDict] ] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = None, + country: Optional[str] = None, + language: Optional[str] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -210,15 +211,16 @@ async def create_async( else: base_url = self._get_url(None, None) - request = models.AnswerRequestBody( + body: dict = dict( query=query, freshness=freshness, - country=country, - language=language, + country=country.upper() if isinstance(country, str) else country, + language=language.upper() if isinstance(language, str) else language, include_domains=utils.unmarshal(include_domains, Optional[List[str]]), exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), ) + request = models.AnswerRequestBody(**body) req = self._build_request_async( method="POST", diff --git a/src/youdotcom/errors/internalservererror_response.py b/src/youdotcom/errors/internalservererror_response.py index 4a8c1a8..1ddc8ce 100644 --- a/src/youdotcom/errors/internalservererror_response.py +++ b/src/youdotcom/errors/internalservererror_response.py @@ -1,15 +1,27 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" +"""Error model for HTTP 500 responses. + +Handles two possible 500 body shapes: + - ``{"detail": "..."}`` — plain detail string + - ``{"errors": [{"status": "500", "code": "...", "title": "...", ...}]}`` — + JSON:API format (returned by controller-level error handlers) + +Both fields are optional so either shape deserializes without crashing. +""" from __future__ import annotations from dataclasses import dataclass, field import httpx -from typing import Optional +from typing import Any, List, Optional from youdotcom.errors import YouError from youdotcom.types import BaseModel class InternalServerErrorResponseData(BaseModel): detail: Optional[str] = None + r"""A description of the error.""" + + errors: Optional[List[dict[str, Any]]] = None + r"""JSON:API error array from controller-level error handlers.""" @dataclass(unsafe_hash=True) diff --git a/src/youdotcom/errors/unprocessableentity_response_error.py b/src/youdotcom/errors/unprocessableentity_response_error.py index f915365..f7696c2 100644 --- a/src/youdotcom/errors/unprocessableentity_response_error.py +++ b/src/youdotcom/errors/unprocessableentity_response_error.py @@ -1,15 +1,34 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" +"""Error model for HTTP 422 responses. + +Handles three possible 422 body shapes returned across You.com endpoints: + - ``{"error": "..."}`` — search spec format + - ``{"detail": [{"type": "...", "loc": [...], "msg": "...", ...}]}`` — FastAPI + request validation errors (returned before handler runs) + - ``{"errors": [{"status": "422", "code": "...", "title": "...", ...}]}`` — + JSON:API format (returned by controller-level error handlers) + +All fields are optional so any shape deserializes without crashing. The raw +response is always preserved on the error object for callers that need the +full body. +""" from __future__ import annotations from dataclasses import dataclass, field import httpx -from typing import Optional +from typing import Any, List, Optional from youdotcom.errors import YouError from youdotcom.types import BaseModel class UnprocessableEntityResponseErrorData(BaseModel): error: Optional[str] = None + r"""Error code from the search spec 422 format.""" + + detail: Optional[List[dict[str, Any]]] = None + r"""Validation error array from FastAPI's RequestValidationError.""" + + errors: Optional[List[dict[str, Any]]] = None + r"""JSON:API error array from controller-level error handlers.""" @dataclass(unsafe_hash=True) diff --git a/tests/test_answer.py b/tests/test_answer.py index 078dd4f..0842e25 100644 --- a/tests/test_answer.py +++ b/tests/test_answer.py @@ -148,6 +148,20 @@ def handler(request): assert "http://custom.local" in captured["url"] assert "/v1/answer" in captured["url"] + def test_lowercase_language_and_country_normalized(self): + """language='en' and country='us' should be normalized to uppercase.""" + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + _sync_you(handler).answer.create(query="test", language="en", country="us") + assert captured["body"]["language"] == "EN" + assert captured["body"]["country"] == "US" + def test_omits_optional_params_when_not_set(self): captured: dict = {} @@ -215,13 +229,34 @@ def test_403_raises_forbidden_error(self): def test_422_raises_unprocessable_entity_error(self): body = json.dumps({"detail": [{"type": "missing", "loc": ["body", "query"], "msg": "Field required"}]}) - with pytest.raises(UnprocessableEntityResponseError): + with pytest.raises(UnprocessableEntityResponseError) as exc_info: + _sync_you(_make_handler(422, body)).answer.create(query="") + # FastAPI validation format: detail array + assert exc_info.value.data.detail is not None + assert exc_info.value.data.detail[0]["type"] == "missing" + + def test_422_json_api_format(self): + """422 in JSON:API format {errors: [{status, code, title, detail}]}.""" + body = json.dumps({"errors": [{"status": "422", "code": "unprocessable_entity", "title": "Unprocessable Entity", "detail": "invalid request parameter(s)"}]}) + with pytest.raises(UnprocessableEntityResponseError) as exc_info: _sync_you(_make_handler(422, body)).answer.create(query="") + assert exc_info.value.data.errors is not None + assert exc_info.value.data.errors[0]["code"] == "unprocessable_entity" - def test_500_raises_internal_server_error(self): - body = json.dumps({"detail": "Internal server error"}) - with pytest.raises(InternalServerErrorResponse): + def test_422_search_spec_format(self): + """422 in search spec format {error: string}.""" + body = json.dumps({"error": "invalid request parameter(s)"}) + with pytest.raises(UnprocessableEntityResponseError) as exc_info: + _sync_you(_make_handler(422, body)).answer.create(query="") + assert exc_info.value.data.error == "invalid request parameter(s)" + + def test_500_with_json_api_errors(self): + """500 in JSON:API format {errors: [...]}.""" + body = json.dumps({"errors": [{"status": "500", "code": "internal_server_error", "title": "Internal Server Error"}]}) + with pytest.raises(InternalServerErrorResponse) as exc_info: _sync_you(_make_handler(500, body)).answer.create(query="test") + assert exc_info.value.data.errors is not None + assert exc_info.value.data.errors[0]["code"] == "internal_server_error" def test_4xx_fallback_raises_default_error(self): body = json.dumps({"detail": "rate limited"}) From 54ac7c72b621ded58fb0b835ccfc916e4415fad4 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 13:31:19 -0700 Subject: [PATCH 07/35] chore: remove Speakeasy disclaimers, update docs for Answer API + keyless search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed "Code generated by Speakeasy — DO NOT EDIT" from 101 source files. The SDK is no longer generated; all files are hand-maintained. Removed: - Speakeasy header docstrings from all source files - "Built by Speakeasy" badge from README - __gen_version__ / SPEAKEASY_GENERATOR_VERSION (unused after removing generator) - speakeasy-sdk prefix from user-agent string Updated docs: - README: Added Answer API to Summary and Available Resources sections - README: Added keyless search section with code example - README: Updated Search API description to mention keyless capability - CHANGELOG: Added [Unreleased] section documenting all changes in this PR Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 15 ++++++++++++ README.md | 24 +++++++++++++++++-- src/youdotcom/__init__.py | 4 +--- src/youdotcom/_hooks/__init__.py | 2 +- src/youdotcom/_hooks/sdkhooks.py | 2 +- src/youdotcom/_hooks/types.py | 2 +- src/youdotcom/_version.py | 4 +--- src/youdotcom/agents.py | 2 +- src/youdotcom/basesdk.py | 2 +- src/youdotcom/contents_sdk.py | 2 +- src/youdotcom/errors/__init__.py | 2 +- .../errors/agentruns400response_error.py | 2 +- .../errors/agentruns401response_error.py | 2 +- .../errors/agentruns422response_error.py | 2 +- src/youdotcom/errors/contentsop.py | 2 +- src/youdotcom/errors/finance_researchop.py | 2 +- .../errors/forbidden_response_error.py | 2 +- src/youdotcom/errors/getresearchtaskop.py | 2 +- src/youdotcom/errors/no_response_error.py | 2 +- src/youdotcom/errors/researchop.py | 2 +- .../errors/responsevalidationerror.py | 2 +- src/youdotcom/errors/streamresearchtaskop.py | 2 +- .../errors/unauthorized_response_error.py | 2 +- src/youdotcom/errors/youdefaulterror.py | 2 +- src/youdotcom/errors/youerror.py | 2 +- src/youdotcom/httpclient.py | 2 +- src/youdotcom/models/__init__.py | 2 +- .../models/advancedagentrunsrequest.py | 2 +- .../models/agentruns422response_error.py | 2 +- .../models/agentrunsbatchresponse.py | 2 +- .../models/agentrunsresponseoutput.py | 2 +- .../agentrunsresponsewebsearchresult.py | 2 +- .../models/agentrunsstreamingresponse.py | 2 +- src/youdotcom/models/agentsrunsop.py | 2 +- src/youdotcom/models/computetool.py | 2 +- src/youdotcom/models/contents.py | 2 +- src/youdotcom/models/contentsformats.py | 2 +- src/youdotcom/models/contentsmetadata.py | 2 +- src/youdotcom/models/contentsop.py | 2 +- src/youdotcom/models/country.py | 2 +- .../models/customagentrunsrequest.py | 2 +- .../models/expressagentrunsrequest.py | 2 +- src/youdotcom/models/finance_researchop.py | 2 +- src/youdotcom/models/financeresearcheffort.py | 2 +- src/youdotcom/models/freshness.py | 2 +- src/youdotcom/models/freshnessvalue.py | 2 +- src/youdotcom/models/getresearchtaskop.py | 2 +- src/youdotcom/models/language.py | 2 +- src/youdotcom/models/livecrawl.py | 2 +- src/youdotcom/models/livecrawlformats.py | 2 +- src/youdotcom/models/newsresult.py | 2 +- src/youdotcom/models/reportverbosity.py | 2 +- src/youdotcom/models/researcheffort.py | 2 +- src/youdotcom/models/researchop.py | 2 +- src/youdotcom/models/researchresponse.py | 2 +- .../models/researchtaskstreamevent.py | 2 +- src/youdotcom/models/researchtool.py | 2 +- src/youdotcom/models/response_created.py | 2 +- src/youdotcom/models/response_done.py | 2 +- .../models/response_output_content_full.py | 2 +- .../models/response_output_item_added.py | 2 +- .../models/response_output_item_done.py | 2 +- .../models/response_output_text_delta.py | 2 +- src/youdotcom/models/response_starting.py | 2 +- src/youdotcom/models/safesearch.py | 2 +- src/youdotcom/models/searcheffort.py | 2 +- src/youdotcom/models/searchmetadata.py | 2 +- src/youdotcom/models/searchop.py | 2 +- src/youdotcom/models/searchpostop.py | 2 +- src/youdotcom/models/searchrequestbody.py | 2 +- src/youdotcom/models/searchresponse.py | 2 +- src/youdotcom/models/security.py | 2 +- src/youdotcom/models/streamresearchtaskop.py | 2 +- src/youdotcom/models/taskdetail.py | 2 +- src/youdotcom/models/taskresponse.py | 2 +- src/youdotcom/models/verbosity.py | 2 +- src/youdotcom/models/webresult.py | 2 +- src/youdotcom/models/websearchtool.py | 2 +- src/youdotcom/runs.py | 2 +- src/youdotcom/sdk.py | 2 +- src/youdotcom/sdkconfiguration.py | 4 +--- src/youdotcom/search.py | 2 +- src/youdotcom/types/__init__.py | 2 +- src/youdotcom/types/base64fileinput.py | 2 +- src/youdotcom/types/basemodel.py | 2 +- src/youdotcom/utils/__init__.py | 2 +- src/youdotcom/utils/annotations.py | 2 +- src/youdotcom/utils/datetimes.py | 2 +- src/youdotcom/utils/dynamic_imports.py | 2 +- src/youdotcom/utils/enums.py | 2 +- src/youdotcom/utils/eventstreaming.py | 2 +- src/youdotcom/utils/forms.py | 2 +- src/youdotcom/utils/headers.py | 2 +- src/youdotcom/utils/logger.py | 2 +- src/youdotcom/utils/metadata.py | 2 +- src/youdotcom/utils/queryparams.py | 2 +- src/youdotcom/utils/requestbodies.py | 2 +- src/youdotcom/utils/retries.py | 2 +- src/youdotcom/utils/security.py | 2 +- src/youdotcom/utils/serializers.py | 2 +- .../utils/unmarshal_json_response.py | 2 +- src/youdotcom/utils/url.py | 2 +- src/youdotcom/utils/values.py | 2 +- 103 files changed, 138 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75363e7..226cf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to the You.com Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Answer API**: New `Answer` sub-SDK — `you.answer.create()` / `you.answer.create_async()` for `POST /v1/answer`. Returns a synthesized markdown answer with inline citations (`[[1, 2]]`), a citations array (source URLs + supporting excerpts), and web results. Accepts `query` (required), `freshness`, `country`, `language`, `include_domains`, `exclude_domains`, `boost_domains`. Requires an API key. Country and language accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase automatically. +- **Keyless search helper**: `search_helpers.search()` / `search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. With no API key, runs in the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, forwards to the full search endpoint. Language strings are normalized to uppercase. +- **`PaymentRequiredResponseError`**: New first-class error class for HTTP 402 responses, matching the `UpgradeRequiredResponse` schema (`error`, `message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). Shared by both search and answer 402 handlers. Replaces the previous `FreeTierLimitError`. + +### Changed + +- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The keyless search proxy at `api.you.com/v1/agents/search` is now the default for all search operations. +- **422 error data model**: `UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. All three 422 response shapes deserialize without crashing. Backward compatible — existing code accessing `.error` still works. +- **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. +- **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. + ## [2.5.0] - 2026-07-20 ### Added diff --git a/README.md b/README.md index d9d0461..e9e2427 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ The official developer-friendly & type-safe Python SDK specifically designed to
- @@ -22,9 +21,10 @@ Multi-step reasoning with comprehensive research capabilities Finance-focused multi-step research with competitive accuracy at same price points and latencies as the Research API Comprehensive API for You.com services: - **Agents API**: Execute queries using Express, Advanced, and Custom AI agents +- **Answer API**: Get synthesized, citation-backed answers grounded in real-time web results - **Research API**: In-depth, multi-step research with citations and sources - **Finance Research API**: Finance-focused multi-step research with citations and sources -- **Search API**: Get search results from web and news sources +- **Search API**: Get search results from web and news sources (keyless-capable via `/v1/agents/search`) - **Contents API**: Retrieve and process web page content @@ -246,6 +246,26 @@ with You( * [unified](docs/sdks/search/README.md#unified) - Returns a list of unified search results from web and news sources +### [Answer](docs/sdks/answer/README.md) + +* [create](docs/sdks/answer/README.md#create) - Returns a synthesized answer with citations from web search results + +### Keyless Search + +The SDK supports keyless search via the `/v1/agents/search` proxy endpoint. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). + +```python +from youdotcom import You +from youdotcom.search_helpers import search + +# No API key — uses the free tier +you = You() +res = search(you, query="What is the capital of France?", count=5) +print(res.results.web[0].title) +``` + +With an API key, the same helper forwards to the full `/v1/search` endpoint with no restrictions. A `402` response raises `PaymentRequiredResponseError` with structured data (`message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). + diff --git a/src/youdotcom/__init__.py b/src/youdotcom/__init__.py index 833c68c..4153b35 100644 --- a/src/youdotcom/__init__.py +++ b/src/youdotcom/__init__.py @@ -1,10 +1,9 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from ._version import ( __title__, __version__, __openapi_doc_version__, - __gen_version__, __user_agent__, ) from .sdk import * @@ -13,5 +12,4 @@ VERSION: str = __version__ OPENAPI_DOC_VERSION = __openapi_doc_version__ -SPEAKEASY_GENERATOR_VERSION = __gen_version__ USER_AGENT = __user_agent__ diff --git a/src/youdotcom/_hooks/__init__.py b/src/youdotcom/_hooks/__init__.py index 2ee66cd..f06758a 100644 --- a/src/youdotcom/_hooks/__init__.py +++ b/src/youdotcom/_hooks/__init__.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .sdkhooks import * from .types import * diff --git a/src/youdotcom/_hooks/sdkhooks.py b/src/youdotcom/_hooks/sdkhooks.py index 48d0589..b2aadbd 100644 --- a/src/youdotcom/_hooks/sdkhooks.py +++ b/src/youdotcom/_hooks/sdkhooks.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import httpx from .types import ( diff --git a/src/youdotcom/_hooks/types.py b/src/youdotcom/_hooks/types.py index 2b03ad3..c41ea53 100644 --- a/src/youdotcom/_hooks/types.py +++ b/src/youdotcom/_hooks/types.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from abc import ABC, abstractmethod import httpx diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 3cd2c70..654ea4e 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -1,12 +1,10 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" import importlib.metadata __title__: str = "youdotcom" __version__: str = "2.5.0" __openapi_doc_version__: str = "1.0.0" -__gen_version__: str = "2.918.1" -__user_agent__: str = "speakeasy-sdk/python 2.5.0 2.918.1 1.0.0 youdotcom" +__user_agent__: str = "youdotcom-python-sdk 2.5.0" try: if __package__ is not None: diff --git a/src/youdotcom/agents.py b/src/youdotcom/agents.py index 9090364..9778321 100644 --- a/src/youdotcom/agents.py +++ b/src/youdotcom/agents.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .basesdk import BaseSDK from .sdkconfiguration import SDKConfiguration diff --git a/src/youdotcom/basesdk.py b/src/youdotcom/basesdk.py index 8665d7f..3429c46 100644 --- a/src/youdotcom/basesdk.py +++ b/src/youdotcom/basesdk.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .sdkconfiguration import SDKConfiguration import httpx diff --git a/src/youdotcom/contents_sdk.py b/src/youdotcom/contents_sdk.py index e7ce613..a7e4c21 100644 --- a/src/youdotcom/contents_sdk.py +++ b/src/youdotcom/contents_sdk.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .basesdk import BaseSDK from typing import Any, Iterable, List, Mapping, Optional diff --git a/src/youdotcom/errors/__init__.py b/src/youdotcom/errors/__init__.py index 870392d..9646be6 100644 --- a/src/youdotcom/errors/__init__.py +++ b/src/youdotcom/errors/__init__.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .youerror import YouError from typing import Any, TYPE_CHECKING diff --git a/src/youdotcom/errors/agentruns400response_error.py b/src/youdotcom/errors/agentruns400response_error.py index bce533b..d8d8aeb 100644 --- a/src/youdotcom/errors/agentruns400response_error.py +++ b/src/youdotcom/errors/agentruns400response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/agentruns401response_error.py b/src/youdotcom/errors/agentruns401response_error.py index 50df678..d89aaa1 100644 --- a/src/youdotcom/errors/agentruns401response_error.py +++ b/src/youdotcom/errors/agentruns401response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/agentruns422response_error.py b/src/youdotcom/errors/agentruns422response_error.py index c86c94a..89dc807 100644 --- a/src/youdotcom/errors/agentruns422response_error.py +++ b/src/youdotcom/errors/agentruns422response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/contentsop.py b/src/youdotcom/errors/contentsop.py index 443fd30..3329e53 100644 --- a/src/youdotcom/errors/contentsop.py +++ b/src/youdotcom/errors/contentsop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/finance_researchop.py b/src/youdotcom/errors/finance_researchop.py index a42f34d..2ce54e6 100644 --- a/src/youdotcom/errors/finance_researchop.py +++ b/src/youdotcom/errors/finance_researchop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/forbidden_response_error.py b/src/youdotcom/errors/forbidden_response_error.py index 575dded..e40a871 100644 --- a/src/youdotcom/errors/forbidden_response_error.py +++ b/src/youdotcom/errors/forbidden_response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/getresearchtaskop.py b/src/youdotcom/errors/getresearchtaskop.py index 1c3cda1..4b5eac8 100644 --- a/src/youdotcom/errors/getresearchtaskop.py +++ b/src/youdotcom/errors/getresearchtaskop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/no_response_error.py b/src/youdotcom/errors/no_response_error.py index 1deab64..f72947a 100644 --- a/src/youdotcom/errors/no_response_error.py +++ b/src/youdotcom/errors/no_response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from dataclasses import dataclass diff --git a/src/youdotcom/errors/researchop.py b/src/youdotcom/errors/researchop.py index a64bebb..e8bfa98 100644 --- a/src/youdotcom/errors/researchop.py +++ b/src/youdotcom/errors/researchop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/responsevalidationerror.py b/src/youdotcom/errors/responsevalidationerror.py index 8e3bb21..f92fdda 100644 --- a/src/youdotcom/errors/responsevalidationerror.py +++ b/src/youdotcom/errors/responsevalidationerror.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import httpx from typing import Optional diff --git a/src/youdotcom/errors/streamresearchtaskop.py b/src/youdotcom/errors/streamresearchtaskop.py index a2ee4a0..c6e64dd 100644 --- a/src/youdotcom/errors/streamresearchtaskop.py +++ b/src/youdotcom/errors/streamresearchtaskop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/unauthorized_response_error.py b/src/youdotcom/errors/unauthorized_response_error.py index dbc2f9f..82c2081 100644 --- a/src/youdotcom/errors/unauthorized_response_error.py +++ b/src/youdotcom/errors/unauthorized_response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from dataclasses import dataclass, field diff --git a/src/youdotcom/errors/youdefaulterror.py b/src/youdotcom/errors/youdefaulterror.py index 795cc56..9bc1e8e 100644 --- a/src/youdotcom/errors/youdefaulterror.py +++ b/src/youdotcom/errors/youdefaulterror.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import httpx from typing import Optional diff --git a/src/youdotcom/errors/youerror.py b/src/youdotcom/errors/youerror.py index cedd8c0..56e1179 100644 --- a/src/youdotcom/errors/youerror.py +++ b/src/youdotcom/errors/youerror.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import httpx from typing import Optional diff --git a/src/youdotcom/httpclient.py b/src/youdotcom/httpclient.py index 89560b5..e0ea6aa 100644 --- a/src/youdotcom/httpclient.py +++ b/src/youdotcom/httpclient.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + # pyright: reportReturnType = false import asyncio diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index d1f1632..4f99729 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import Any, TYPE_CHECKING diff --git a/src/youdotcom/models/advancedagentrunsrequest.py b/src/youdotcom/models/advancedagentrunsrequest.py index 8511c0a..8519e5c 100644 --- a/src/youdotcom/models/advancedagentrunsrequest.py +++ b/src/youdotcom/models/advancedagentrunsrequest.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .computetool import ComputeTool, ComputeToolTypedDict diff --git a/src/youdotcom/models/agentruns422response_error.py b/src/youdotcom/models/agentruns422response_error.py index 4124056..2d004f7 100644 --- a/src/youdotcom/models/agentruns422response_error.py +++ b/src/youdotcom/models/agentruns422response_error.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from typing import List, Union diff --git a/src/youdotcom/models/agentrunsbatchresponse.py b/src/youdotcom/models/agentrunsbatchresponse.py index df8614a..de4e1c8 100644 --- a/src/youdotcom/models/agentrunsbatchresponse.py +++ b/src/youdotcom/models/agentrunsbatchresponse.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .agentrunsresponseoutput import ( diff --git a/src/youdotcom/models/agentrunsresponseoutput.py b/src/youdotcom/models/agentrunsresponseoutput.py index 1eaa631..f0213af 100644 --- a/src/youdotcom/models/agentrunsresponseoutput.py +++ b/src/youdotcom/models/agentrunsresponseoutput.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .agentrunsresponsewebsearchresult import ( diff --git a/src/youdotcom/models/agentrunsresponsewebsearchresult.py b/src/youdotcom/models/agentrunsresponsewebsearchresult.py index 7f9d3c3..0a1394f 100644 --- a/src/youdotcom/models/agentrunsresponsewebsearchresult.py +++ b/src/youdotcom/models/agentrunsresponsewebsearchresult.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/agentrunsstreamingresponse.py b/src/youdotcom/models/agentrunsstreamingresponse.py index 1a2c119..71e329f 100644 --- a/src/youdotcom/models/agentrunsstreamingresponse.py +++ b/src/youdotcom/models/agentrunsstreamingresponse.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .response_created import ResponseCreated, ResponseCreatedTypedDict diff --git a/src/youdotcom/models/agentsrunsop.py b/src/youdotcom/models/agentsrunsop.py index 52a57d5..2b7565d 100644 --- a/src/youdotcom/models/agentsrunsop.py +++ b/src/youdotcom/models/agentsrunsop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .advancedagentrunsrequest import ( diff --git a/src/youdotcom/models/computetool.py b/src/youdotcom/models/computetool.py index 1253881..3fc3886 100644 --- a/src/youdotcom/models/computetool.py +++ b/src/youdotcom/models/computetool.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/contents.py b/src/youdotcom/models/contents.py index 37d69b8..f2423a3 100644 --- a/src/youdotcom/models/contents.py +++ b/src/youdotcom/models/contents.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/contentsformats.py b/src/youdotcom/models/contentsformats.py index 91f16e0..c7d8ee9 100644 --- a/src/youdotcom/models/contentsformats.py +++ b/src/youdotcom/models/contentsformats.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/contentsmetadata.py b/src/youdotcom/models/contentsmetadata.py index b6b8e50..bba55c3 100644 --- a/src/youdotcom/models/contentsmetadata.py +++ b/src/youdotcom/models/contentsmetadata.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/contentsop.py b/src/youdotcom/models/contentsop.py index b3ff6a2..2b59919 100644 --- a/src/youdotcom/models/contentsop.py +++ b/src/youdotcom/models/contentsop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .contentsformats import ContentsFormats diff --git a/src/youdotcom/models/country.py b/src/youdotcom/models/country.py index 720e606..8169402 100644 --- a/src/youdotcom/models/country.py +++ b/src/youdotcom/models/country.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/customagentrunsrequest.py b/src/youdotcom/models/customagentrunsrequest.py index 181e560..285dcf7 100644 --- a/src/youdotcom/models/customagentrunsrequest.py +++ b/src/youdotcom/models/customagentrunsrequest.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/expressagentrunsrequest.py b/src/youdotcom/models/expressagentrunsrequest.py index 8211741..85d2910 100644 --- a/src/youdotcom/models/expressagentrunsrequest.py +++ b/src/youdotcom/models/expressagentrunsrequest.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .websearchtool import WebSearchTool, WebSearchToolTypedDict diff --git a/src/youdotcom/models/finance_researchop.py b/src/youdotcom/models/finance_researchop.py index 47259fd..f28fc6d 100644 --- a/src/youdotcom/models/finance_researchop.py +++ b/src/youdotcom/models/finance_researchop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .financeresearcheffort import FinanceResearchEffort diff --git a/src/youdotcom/models/financeresearcheffort.py b/src/youdotcom/models/financeresearcheffort.py index 909b887..9478b0f 100644 --- a/src/youdotcom/models/financeresearcheffort.py +++ b/src/youdotcom/models/financeresearcheffort.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + # Manual overlay: `lite` tier added via overlays/python_overlay.yaml # until the upstream OpenAPI spec includes it. See that file for details. diff --git a/src/youdotcom/models/freshness.py b/src/youdotcom/models/freshness.py index 83281eb..5228076 100644 --- a/src/youdotcom/models/freshness.py +++ b/src/youdotcom/models/freshness.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/freshnessvalue.py b/src/youdotcom/models/freshnessvalue.py index 66ff778..8742860 100644 --- a/src/youdotcom/models/freshnessvalue.py +++ b/src/youdotcom/models/freshnessvalue.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .freshness import Freshness diff --git a/src/youdotcom/models/getresearchtaskop.py b/src/youdotcom/models/getresearchtaskop.py index 0330943..914f673 100644 --- a/src/youdotcom/models/getresearchtaskop.py +++ b/src/youdotcom/models/getresearchtaskop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from typing_extensions import Annotated, TypedDict diff --git a/src/youdotcom/models/language.py b/src/youdotcom/models/language.py index 83704f1..852c77d 100644 --- a/src/youdotcom/models/language.py +++ b/src/youdotcom/models/language.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/livecrawl.py b/src/youdotcom/models/livecrawl.py index c28464e..aac7e1d 100644 --- a/src/youdotcom/models/livecrawl.py +++ b/src/youdotcom/models/livecrawl.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/livecrawlformats.py b/src/youdotcom/models/livecrawlformats.py index ceca02d..18eb4cd 100644 --- a/src/youdotcom/models/livecrawlformats.py +++ b/src/youdotcom/models/livecrawlformats.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/newsresult.py b/src/youdotcom/models/newsresult.py index 9a89295..d414648 100644 --- a/src/youdotcom/models/newsresult.py +++ b/src/youdotcom/models/newsresult.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .contents import Contents, ContentsTypedDict diff --git a/src/youdotcom/models/reportverbosity.py b/src/youdotcom/models/reportverbosity.py index 14a8b43..a767bb7 100644 --- a/src/youdotcom/models/reportverbosity.py +++ b/src/youdotcom/models/reportverbosity.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/researcheffort.py b/src/youdotcom/models/researcheffort.py index 506ea33..4da2dcf 100644 --- a/src/youdotcom/models/researcheffort.py +++ b/src/youdotcom/models/researcheffort.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + # Manual overlay: `frontier` tier added via overlays/python_overlay.yaml # until the upstream OpenAPI spec includes it. See that file for details. diff --git a/src/youdotcom/models/researchop.py b/src/youdotcom/models/researchop.py index 15349a0..151bc33 100644 --- a/src/youdotcom/models/researchop.py +++ b/src/youdotcom/models/researchop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .researcheffort import ResearchEffort diff --git a/src/youdotcom/models/researchresponse.py b/src/youdotcom/models/researchresponse.py index 8e0ef6c..f1a29b1 100644 --- a/src/youdotcom/models/researchresponse.py +++ b/src/youdotcom/models/researchresponse.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/researchtaskstreamevent.py b/src/youdotcom/models/researchtaskstreamevent.py index fe7f123..6596a05 100644 --- a/src/youdotcom/models/researchtaskstreamevent.py +++ b/src/youdotcom/models/researchtaskstreamevent.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/researchtool.py b/src/youdotcom/models/researchtool.py index 445f60c..e2465aa 100644 --- a/src/youdotcom/models/researchtool.py +++ b/src/youdotcom/models/researchtool.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .reportverbosity import ReportVerbosity diff --git a/src/youdotcom/models/response_created.py b/src/youdotcom/models/response_created.py index 509085d..9385569 100644 --- a/src/youdotcom/models/response_created.py +++ b/src/youdotcom/models/response_created.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/response_done.py b/src/youdotcom/models/response_done.py index 599f8a7..152b8af 100644 --- a/src/youdotcom/models/response_done.py +++ b/src/youdotcom/models/response_done.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/response_output_content_full.py b/src/youdotcom/models/response_output_content_full.py index b2bd70c..00e55df 100644 --- a/src/youdotcom/models/response_output_content_full.py +++ b/src/youdotcom/models/response_output_content_full.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .agentrunsresponsewebsearchresult import ( diff --git a/src/youdotcom/models/response_output_item_added.py b/src/youdotcom/models/response_output_item_added.py index 7294962..78fcf52 100644 --- a/src/youdotcom/models/response_output_item_added.py +++ b/src/youdotcom/models/response_output_item_added.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/response_output_item_done.py b/src/youdotcom/models/response_output_item_done.py index 54f4cbc..240cb11 100644 --- a/src/youdotcom/models/response_output_item_done.py +++ b/src/youdotcom/models/response_output_item_done.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/response_output_text_delta.py b/src/youdotcom/models/response_output_text_delta.py index 36aa5a4..de88170 100644 --- a/src/youdotcom/models/response_output_text_delta.py +++ b/src/youdotcom/models/response_output_text_delta.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/response_starting.py b/src/youdotcom/models/response_starting.py index 438a191..47b292c 100644 --- a/src/youdotcom/models/response_starting.py +++ b/src/youdotcom/models/response_starting.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/models/safesearch.py b/src/youdotcom/models/safesearch.py index 5a64287..bb32a49 100644 --- a/src/youdotcom/models/safesearch.py +++ b/src/youdotcom/models/safesearch.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/searcheffort.py b/src/youdotcom/models/searcheffort.py index cec092e..9d4331d 100644 --- a/src/youdotcom/models/searcheffort.py +++ b/src/youdotcom/models/searcheffort.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/searchmetadata.py b/src/youdotcom/models/searchmetadata.py index 758b0c2..f788e74 100644 --- a/src/youdotcom/models/searchmetadata.py +++ b/src/youdotcom/models/searchmetadata.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/searchop.py b/src/youdotcom/models/searchop.py index fe588ea..e2e3120 100644 --- a/src/youdotcom/models/searchop.py +++ b/src/youdotcom/models/searchop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .country import Country diff --git a/src/youdotcom/models/searchpostop.py b/src/youdotcom/models/searchpostop.py index 0b91952..5f22c29 100644 --- a/src/youdotcom/models/searchpostop.py +++ b/src/youdotcom/models/searchpostop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations diff --git a/src/youdotcom/models/searchrequestbody.py b/src/youdotcom/models/searchrequestbody.py index df94c4c..247db23 100644 --- a/src/youdotcom/models/searchrequestbody.py +++ b/src/youdotcom/models/searchrequestbody.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .country import Country diff --git a/src/youdotcom/models/searchresponse.py b/src/youdotcom/models/searchresponse.py index ee9313e..e17262f 100644 --- a/src/youdotcom/models/searchresponse.py +++ b/src/youdotcom/models/searchresponse.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .newsresult import NewsResult, NewsResultTypedDict diff --git a/src/youdotcom/models/security.py b/src/youdotcom/models/security.py index 2313b52..765679f 100644 --- a/src/youdotcom/models/security.py +++ b/src/youdotcom/models/security.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/streamresearchtaskop.py b/src/youdotcom/models/streamresearchtaskop.py index 6b57b1d..8bc0384 100644 --- a/src/youdotcom/models/streamresearchtaskop.py +++ b/src/youdotcom/models/streamresearchtaskop.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from pydantic import model_serializer diff --git a/src/youdotcom/models/taskdetail.py b/src/youdotcom/models/taskdetail.py index f039294..a8e4a6c 100644 --- a/src/youdotcom/models/taskdetail.py +++ b/src/youdotcom/models/taskdetail.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from datetime import datetime diff --git a/src/youdotcom/models/taskresponse.py b/src/youdotcom/models/taskresponse.py index 490476d..3f5fe9a 100644 --- a/src/youdotcom/models/taskresponse.py +++ b/src/youdotcom/models/taskresponse.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from datetime import datetime diff --git a/src/youdotcom/models/verbosity.py b/src/youdotcom/models/verbosity.py index 666d75a..a87e176 100644 --- a/src/youdotcom/models/verbosity.py +++ b/src/youdotcom/models/verbosity.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from enum import Enum diff --git a/src/youdotcom/models/webresult.py b/src/youdotcom/models/webresult.py index 27df725..73edde0 100644 --- a/src/youdotcom/models/webresult.py +++ b/src/youdotcom/models/webresult.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations from .contents import Contents, ContentsTypedDict diff --git a/src/youdotcom/models/websearchtool.py b/src/youdotcom/models/websearchtool.py index 7834ec3..b396e44 100644 --- a/src/youdotcom/models/websearchtool.py +++ b/src/youdotcom/models/websearchtool.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations import pydantic diff --git a/src/youdotcom/runs.py b/src/youdotcom/runs.py index 0c63569..9fb8910 100644 --- a/src/youdotcom/runs.py +++ b/src/youdotcom/runs.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .basesdk import BaseSDK from typing import Any, Mapping, Optional, Union, cast diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 8acc5ae..c0475e6 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .basesdk import BaseSDK from .httpclient import AsyncHttpClient, ClientOwner, HttpClient, close_clients diff --git a/src/youdotcom/sdkconfiguration.py b/src/youdotcom/sdkconfiguration.py index ea49a89..ec05e3e 100644 --- a/src/youdotcom/sdkconfiguration.py +++ b/src/youdotcom/sdkconfiguration.py @@ -1,7 +1,6 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from ._version import ( - __gen_version__, __openapi_doc_version__, __user_agent__, __version__, @@ -34,7 +33,6 @@ class SDKConfiguration: language: str = "python" openapi_doc_version: str = __openapi_doc_version__ sdk_version: str = __version__ - gen_version: str = __gen_version__ user_agent: str = __user_agent__ retry_config: OptionalNullable[RetryConfig] = Field(default_factory=lambda: UNSET) timeout_ms: Optional[int] = None diff --git a/src/youdotcom/search.py b/src/youdotcom/search.py index 9e69fa0..3c9accb 100644 --- a/src/youdotcom/search.py +++ b/src/youdotcom/search.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .basesdk import BaseSDK from typing import Any, Iterable, List, Mapping, Optional, Union diff --git a/src/youdotcom/types/__init__.py b/src/youdotcom/types/__init__.py index faa2681..74b9dc1 100644 --- a/src/youdotcom/types/__init__.py +++ b/src/youdotcom/types/__init__.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from .base64fileinput import Base64EncodedString, Base64FileInput from .basemodel import ( diff --git a/src/youdotcom/types/base64fileinput.py b/src/youdotcom/types/base64fileinput.py index 862566f..9816c09 100644 --- a/src/youdotcom/types/base64fileinput.py +++ b/src/youdotcom/types/base64fileinput.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from __future__ import annotations diff --git a/src/youdotcom/types/basemodel.py b/src/youdotcom/types/basemodel.py index a9a640a..d0c2583 100644 --- a/src/youdotcom/types/basemodel.py +++ b/src/youdotcom/types/basemodel.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from pydantic import ConfigDict, model_serializer from pydantic import BaseModel as PydanticBaseModel diff --git a/src/youdotcom/utils/__init__.py b/src/youdotcom/utils/__init__.py index c48a36c..81eacf5 100644 --- a/src/youdotcom/utils/__init__.py +++ b/src/youdotcom/utils/__init__.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import Any, TYPE_CHECKING, Callable, TypeVar import asyncio diff --git a/src/youdotcom/utils/annotations.py b/src/youdotcom/utils/annotations.py index 12e0aa4..188c8f6 100644 --- a/src/youdotcom/utils/annotations.py +++ b/src/youdotcom/utils/annotations.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from enum import Enum from typing import Any, Optional diff --git a/src/youdotcom/utils/datetimes.py b/src/youdotcom/utils/datetimes.py index adad247..6452522 100644 --- a/src/youdotcom/utils/datetimes.py +++ b/src/youdotcom/utils/datetimes.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from datetime import datetime, timedelta import sys diff --git a/src/youdotcom/utils/dynamic_imports.py b/src/youdotcom/utils/dynamic_imports.py index 673edf8..7f88737 100644 --- a/src/youdotcom/utils/dynamic_imports.py +++ b/src/youdotcom/utils/dynamic_imports.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from importlib import import_module import builtins diff --git a/src/youdotcom/utils/enums.py b/src/youdotcom/utils/enums.py index 3324e1b..b56be22 100644 --- a/src/youdotcom/utils/enums.py +++ b/src/youdotcom/utils/enums.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import enum import sys diff --git a/src/youdotcom/utils/eventstreaming.py b/src/youdotcom/utils/eventstreaming.py index a8d4fe5..09b85c1 100644 --- a/src/youdotcom/utils/eventstreaming.py +++ b/src/youdotcom/utils/eventstreaming.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import re import json diff --git a/src/youdotcom/utils/forms.py b/src/youdotcom/utils/forms.py index 193f264..5d1ccb0 100644 --- a/src/youdotcom/utils/forms.py +++ b/src/youdotcom/utils/forms.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import io from typing import ( diff --git a/src/youdotcom/utils/headers.py b/src/youdotcom/utils/headers.py index 37864cb..5e938f0 100644 --- a/src/youdotcom/utils/headers.py +++ b/src/youdotcom/utils/headers.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import ( Any, diff --git a/src/youdotcom/utils/logger.py b/src/youdotcom/utils/logger.py index 6ae3abd..cd063d5 100644 --- a/src/youdotcom/utils/logger.py +++ b/src/youdotcom/utils/logger.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import httpx import logging diff --git a/src/youdotcom/utils/metadata.py b/src/youdotcom/utils/metadata.py index 5abddd5..a70634a 100644 --- a/src/youdotcom/utils/metadata.py +++ b/src/youdotcom/utils/metadata.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import Optional, Type, TypeVar, Union from dataclasses import dataclass diff --git a/src/youdotcom/utils/queryparams.py b/src/youdotcom/utils/queryparams.py index c04e0db..f831b89 100644 --- a/src/youdotcom/utils/queryparams.py +++ b/src/youdotcom/utils/queryparams.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import ( Any, diff --git a/src/youdotcom/utils/requestbodies.py b/src/youdotcom/utils/requestbodies.py index 591415a..17aba70 100644 --- a/src/youdotcom/utils/requestbodies.py +++ b/src/youdotcom/utils/requestbodies.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import io from dataclasses import dataclass diff --git a/src/youdotcom/utils/retries.py b/src/youdotcom/utils/retries.py index ca7b59e..2a7a52d 100644 --- a/src/youdotcom/utils/retries.py +++ b/src/youdotcom/utils/retries.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import asyncio import random diff --git a/src/youdotcom/utils/security.py b/src/youdotcom/utils/security.py index 2468bf1..3feb799 100644 --- a/src/youdotcom/utils/security.py +++ b/src/youdotcom/utils/security.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + import base64 diff --git a/src/youdotcom/utils/serializers.py b/src/youdotcom/utils/serializers.py index 1031ed9..7ca3db3 100644 --- a/src/youdotcom/utils/serializers.py +++ b/src/youdotcom/utils/serializers.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from decimal import Decimal import functools diff --git a/src/youdotcom/utils/unmarshal_json_response.py b/src/youdotcom/utils/unmarshal_json_response.py index 131b392..12d397d 100644 --- a/src/youdotcom/utils/unmarshal_json_response.py +++ b/src/youdotcom/utils/unmarshal_json_response.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from typing import Any, Optional, Type, TypeVar, overload diff --git a/src/youdotcom/utils/url.py b/src/youdotcom/utils/url.py index c78ccba..5c7be7f 100644 --- a/src/youdotcom/utils/url.py +++ b/src/youdotcom/utils/url.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from decimal import Decimal from typing import ( diff --git a/src/youdotcom/utils/values.py b/src/youdotcom/utils/values.py index dae01a4..6e25f4b 100644 --- a/src/youdotcom/utils/values.py +++ b/src/youdotcom/utils/values.py @@ -1,4 +1,4 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" + from datetime import datetime from enum import Enum From 623e4f5690108eb48172113dbeea32cf0d19e0bb Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 13:41:04 -0700 Subject: [PATCH 08/35] docs: update all SDK documentation for Answer API + keyless search + error changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New docs: - docs/sdks/answer/README.md: Answer sub-SDK with create() method, params, errors - docs/errors/paymentrequiredresponseerror.md: 402 error with 7 fields - docs/models/answerresponse.md, answercitation.md, answersearchresult.md, answerrequestbody.md: model reference docs Updated docs: - docs/errors/unprocessableentityresponseerror.md: added detail + errors fields for FastAPI validation and JSON:API 422 shapes - docs/errors/internalservererrorresponse.md: added errors field for JSON:API 500 shape - docs/sdks/you/README.md: added Answer API to summary - CONTRIBUTING.md: replaced "generated code, no PRs accepted" with hand-maintained PR guide and dev setup instructions - MIGRATION.md: added 2.5.0 → Unreleased section covering Answer API, keyless search, host change, FreeTierLimitError→PaymentRequiredResponseError, 422/500 error model expansion, and Speakeasy removal Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 19 +++++- MIGRATION.md | 52 ++++++++++++++- docs/errors/internalservererrorresponse.md | 11 +++- docs/errors/paymentrequiredresponseerror.md | 16 +++++ .../unprocessableentityresponseerror.md | 13 +++- docs/models/answercitation.md | 11 ++++ docs/models/answerrequestbody.md | 16 +++++ docs/models/answerresponse.md | 12 ++++ docs/models/answersearchresult.md | 13 ++++ docs/sdks/answer/README.md | 63 +++++++++++++++++++ docs/sdks/you/README.md | 3 +- 11 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 docs/errors/paymentrequiredresponseerror.md create mode 100644 docs/models/answercitation.md create mode 100644 docs/models/answerrequestbody.md create mode 100644 docs/models/answerresponse.md create mode 100644 docs/models/answersearchresult.md create mode 100644 docs/sdks/answer/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d585717..3927f62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to This Repository -Thank you for your interest in contributing to this repository. Please note that this repository contains generated code. As such, we do not accept direct changes or pull requests. Instead, we encourage you to follow the guidelines below to report issues and suggest improvements. +Thank you for your interest in contributing to the You.com Python SDK! This SDK is hand-maintained (not generated) and we welcome pull requests. ## How to Report Issues @@ -13,9 +13,22 @@ If you encounter any bugs or have suggestions for improvements, please open an i - Information about your environment (e.g., operating system, software versions) - For example can be collected using the `npx envinfo` command from your terminal if you have Node.js installed -## Issue Triage and Upstream Fixes +## Pull Requests -We will review and triage issues as quickly as possible. Our goal is to address bugs and incorporate improvements in the upstream source code. Fixes will be included in the next generation of the generated code. +1. Fork the repository and create a branch from `main`. +2. Make your changes. Follow existing code style and patterns. +3. Add or update tests as needed. Run `pytest tests/ --ignore=tests/test_live.py` for unit tests (live tests require `YDC_API_KEY`). +4. Run `mypy src/youdotcom/` to ensure type safety. +5. Update documentation (README, CHANGELOG, `docs/` directory) if your change adds or modifies public API surface. +6. Open a pull request with a clear description of the change. + +## Development Setup + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` ## Contact diff --git a/MIGRATION.md b/MIGRATION.md index 1a52ebd..a5d44fd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,56 @@ # Migration Guide -## 2.4.0 → 2.5.0 (Latest) +## 2.5.0 → Unreleased + +### Answer API + +New `Answer` sub-SDK for `POST /v1/answer`: + +```python +from youdotcom import You + +with You(api_key_auth=os.getenv("YDC_API_KEY")) as you: + res = you.answer.create(query="What causes the 2008 financial crisis?") + print(res.answer) # markdown with [[1, 2]] citations + print(res.citations[0].source) # source URL + print(res.results.web[0].title) # web result title +``` + +Requires an API key. `country` and `language` accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase. + +### Keyless Search + +`search_helpers.search()` / `search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). + +### Host Change: ydc-index.io → api.you.com + +`SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com`. This aligns with the MCP server and published docs. No code changes required — the SDK resolves the server automatically. + +### FreeTierLimitError → PaymentRequiredResponseError + +The standalone `FreeTierLimitError` exception in `search_helpers.py` has been replaced with the first-class `PaymentRequiredResponseError` (extends `YouError`). The new error provides structured data: + +```python +from youdotcom.errors import PaymentRequiredResponseError + +try: + search(you, query="test", count=100) # exceeds free tier +except PaymentRequiredResponseError as e: + print(e.data.message) # "Insufficient credits" + print(e.data.upgrade_url) # "https://you.com/platform" + print(e.data.limit) # 100 + print(e.data.reset_at) # "2026-08-05T00:00:00Z" +``` + +### 422/500 Error Models Expanded + +`UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. `InternalServerErrorResponseData` now includes an optional `errors` field. These are additive — existing code accessing `.error` or `.detail` still works. + +### No Longer Generated by Speakeasy + +The SDK is now hand-maintained. All "Code generated by Speakeasy — DO NOT EDIT" disclaimers have been removed. The `__gen_version__` / `SPEAKEASY_GENERATOR_VERSION` exports have been removed. + +## 2.4.0 → 2.5.0 ### New `frontier` Research Effort Tier diff --git a/docs/errors/internalservererrorresponse.md b/docs/errors/internalservererrorresponse.md index b99d7b0..25fa818 100644 --- a/docs/errors/internalservererrorresponse.md +++ b/docs/errors/internalservererrorresponse.md @@ -2,9 +2,14 @@ Internal Server Error during authentication/authorization middleware. +Handles two possible 500 body shapes: +- `{"detail": "..."}` — plain detail string +- `{"errors": [{"status": "500", "code": "...", "title": "...", ...}]}` — JSON:API format + ## Fields -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `detail` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `detail` | *Optional[str]* | :heavy_minus_sign: | A description of the error. | +| `errors` | *Optional[List[dict]]* | :heavy_minus_sign: | JSON:API error array from controller-level error handlers. | \ No newline at end of file diff --git a/docs/errors/paymentrequiredresponseerror.md b/docs/errors/paymentrequiredresponseerror.md new file mode 100644 index 0000000..4b4872e --- /dev/null +++ b/docs/errors/paymentrequiredresponseerror.md @@ -0,0 +1,16 @@ +# PaymentRequiredResponseError + +Payment Required (402). The account cannot make paid API requests. + + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `error` | *Optional[str]* | :heavy_minus_sign: | The error code (e.g. `"payment_required"`). | +| `message` | *Optional[str]* | :heavy_minus_sign: | A human-readable description of the error. | +| `upgrade_url` | *Optional[str]* | :heavy_minus_sign: | URL for adding credits or upgrading the account. | +| `limit` | *Optional[int]* | :heavy_minus_sign: | The usage limit, when available. | +| `used` | *Optional[int]* | :heavy_minus_sign: | The usage consumed, when available. | +| `period` | *Optional[str]* | :heavy_minus_sign: | The usage period, when available. | +| `reset_at` | *Optional[str]* | :heavy_minus_sign: | The reset timestamp, when available. | diff --git a/docs/errors/unprocessableentityresponseerror.md b/docs/errors/unprocessableentityresponseerror.md index 5d44499..214b6b0 100644 --- a/docs/errors/unprocessableentityresponseerror.md +++ b/docs/errors/unprocessableentityresponseerror.md @@ -2,9 +2,16 @@ Unprocessable Entity. Invalid request parameter combination. +Handles three possible 422 body shapes: +- `{"error": "..."}` — search spec format +- `{"detail": [{"type": "...", "loc": [...], "msg": "...", ...}]}` — FastAPI validation errors +- `{"errors": [{"status": "422", "code": "...", "title": "...", ...}]}` — JSON:API format + ## Fields -| Field | Type | Required | Description | -| ------------------ | ------------------ | ------------------ | ------------------ | -| `error` | *Optional[str]* | :heavy_minus_sign: | N/A | \ No newline at end of file +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `error` | *Optional[str]* | :heavy_minus_sign: | Error code from the search spec 422 format. | +| `detail` | *Optional[List[dict]]* | :heavy_minus_sign: | Validation error array from FastAPI's RequestValidationError. | +| `errors` | *Optional[List[dict]]* | :heavy_minus_sign: | JSON:API error array from controller-level error handlers. | \ No newline at end of file diff --git a/docs/models/answercitation.md b/docs/models/answercitation.md new file mode 100644 index 0000000..f3d7259 --- /dev/null +++ b/docs/models/answercitation.md @@ -0,0 +1,11 @@ +# AnswerCitation + +A source cited in the answer, with supporting excerpts. + + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `source` | *str* | :heavy_check_mark: | The URL of the cited source. | +| `excerpts` | List[*str*] | :heavy_minus_sign: | Verbatim excerpts from the cited source that support the answer. | diff --git a/docs/models/answerrequestbody.md b/docs/models/answerrequestbody.md new file mode 100644 index 0000000..25cb9f5 --- /dev/null +++ b/docs/models/answerrequestbody.md @@ -0,0 +1,16 @@ +# AnswerRequestBody + +Request body for `POST /v1/answer`. + + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `query` | *str* | :heavy_check_mark: | The search query used to retrieve relevant web results. Max 400 characters. Search operators (`site:`, `OR`, etc.) are not supported. | +| `freshness` | [Optional[models.FreshnessValue]](../models/freshnessvalue.md) | :heavy_minus_sign: | Specifies the freshness of the results. One of `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD`. | +| `country` | [Optional[models.Country]](../models/country.md) | :heavy_minus_sign: | A supported country code that determines the geographical focus of the web results. | +| `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | A supported BCP 47 language tag that determines the language of the web results. | +| `include_domains` | List[*str*] | :heavy_minus_sign: | Domains to exclusively include. Cannot combine with `exclude_domains` or `boost_domains`. Max 500. | +| `exclude_domains` | List[*str*] | :heavy_minus_sign: | Domains to exclude. Cannot combine with `include_domains`. Can combine with `boost_domains`. Max 500. | +| `boost_domains` | List[*str*] | :heavy_minus_sign: | Domains to prefer in ranking. Cannot combine with `include_domains`. Can combine with `exclude_domains`. Max 500. | diff --git a/docs/models/answerresponse.md b/docs/models/answerresponse.md new file mode 100644 index 0000000..d3fbfbe --- /dev/null +++ b/docs/models/answerresponse.md @@ -0,0 +1,12 @@ +# AnswerResponse + +A synthesized answer with citations and supporting search results. + + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `answer` | *str* | :heavy_check_mark: | The synthesized response with numbered inline citations that reference items in the `citations` array. | +| `citations` | List[[models.AnswerCitation](../models/answercitation.md)] | :heavy_minus_sign: | The sources cited in the answer, in citation order. | +| `results` | [models.AnswerResults](../models/answerresponse.md) | :heavy_minus_sign: | Search results grouped by result type. Contains a `web` array of [AnswerSearchResult](../models/answersearchresult.md). | diff --git a/docs/models/answersearchresult.md b/docs/models/answersearchresult.md new file mode 100644 index 0000000..a45ec70 --- /dev/null +++ b/docs/models/answersearchresult.md @@ -0,0 +1,13 @@ +# AnswerSearchResult + +A web search result used during answer synthesis. + + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | +| `title` | *str* | :heavy_check_mark: | The title of the source webpage. | +| `snippets` | List[*str*] | :heavy_minus_sign: | Text snippets from the search result that preview its content. | +| `page_age` | *Optional[str]* | :heavy_minus_sign: | The publication date or age supplied by the search result. | diff --git a/docs/sdks/answer/README.md b/docs/sdks/answer/README.md new file mode 100644 index 0000000..8cc50c4 --- /dev/null +++ b/docs/sdks/answer/README.md @@ -0,0 +1,63 @@ +# Answer + +## Overview + +The Answer API returns a synthesized natural-language answer with citations and the web results used to generate it. Send a `query` with optional freshness, locale, and domain controls. + +### Available Operations + +* [create](#create) - Returns a synthesized answer with citations from web search results + +## create + +Returns a synthesized natural-language answer with citations and the web results used to generate it. Provide a `query` and optional freshness, locale, and domain controls. + +### Example Usage + +```python +import os +from youdotcom import You + + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + + res = you.answer.create(query="What are the main causes of the 2008 financial crisis?") + + # Handle response + print(res.answer) + for citation in res.citations: + print(f" [{citation.source}] {citation.excerpts[0]}") +``` + +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | *str* | :heavy_check_mark: | The search query. Max 400 characters. Search operators (`site:`, `OR`, etc.) are not supported. | +| `freshness` | *Optional[str]* | :heavy_minus_sign: | `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD` | +| `country` | *Optional[str]* | :heavy_minus_sign: | Country code (e.g. `US`, `GB`, `FR`). Normalized to uppercase. | +| `language` | *Optional[str]* | :heavy_minus_sign: | BCP 47 language tag (e.g. `EN`, `EN-GB`, `FR`). Normalized to uppercase. | +| `include_domains` | *Optional[List[str]]* | :heavy_minus_sign: | Domains to exclusively include. Cannot combine with `exclude_domains` or `boost_domains`. Max 500. | +| `exclude_domains` | *Optional[List[str]]* | :heavy_minus_sign: | Domains to exclude. Cannot combine with `include_domains`. Can combine with `boost_domains`. Max 500. | +| `boost_domains` | *Optional[List[str]]* | :heavy_minus_sign: | Domains to prefer in ranking. Cannot combine with `include_domains`. Can combine with `exclude_domains`. Max 500. | +| `retries` | [Optional[utils.RetryConfig]](../../models/utils/retryconfig.md) | :heavy_minus_sign: | Configuration to override the default retry behavior of the client. | +| `server_url` | *Optional[str]* | :heavy_minus_sign: | An optional server URL to use. | +| `timeout_ms` | *Optional[int]* | :heavy_minus_sign: | Override the default request timeout in milliseconds. | +| `http_headers` | *Optional[Mapping[str, str]]* | :heavy_minus_sign: | Additional headers to set or replace on requests. | + +### Response + +**[models.AnswerResponse](../../models/answerresponse.md)** + +### Errors + +| Error Type | Status Code | Content Type | +|------------|-------------|-------------| +| errors.UnauthorizedResponseError | 401 | application/json | +| errors.PaymentRequiredResponseError | 402 | application/json | +| errors.ForbiddenResponseError | 403 | application/json | +| errors.UnprocessableEntityResponseError | 422 | application/json | +| errors.InternalServerErrorResponse | 500 | application/json | +| errors.YouDefaultError | 4XX, 5XX | \*/\* | diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 58c8b5a..00a8d5f 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -9,9 +9,10 @@ Multi-step reasoning with comprehensive research capabilities Finance-focused multi-step research with competitive accuracy at same price points and latencies as the Research API Comprehensive API for You.com services: - **Agents API**: Execute queries using Express, Advanced, and Custom AI agents +- **Answer API**: Get synthesized, citation-backed answers grounded in real-time web results - **Research API**: In-depth, multi-step research with citations and sources - **Finance Research API**: Finance-focused multi-step research with citations and sources -- **Search API**: Get search results from web and news sources +- **Search API**: Get search results from web and news sources (keyless-capable via `/v1/agents/search`) - **Contents API**: Retrieve and process web page content ### Available Operations From 360c50ab4585d37ef80334801d57f515fce69d05 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 13:50:43 -0700 Subject: [PATCH 09/35] docs: remove remaining Speakeasy attribution from README, document AnswerResults fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove "SDK Created by Speakeasy" footer and placeholder comment from README - Add AnswerResults fields section to answerresponse.md (web field was described inline but not in a proper table) Found during SDK docs drift check — all other doc files match source exactly. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 4 ---- docs/models/answerresponse.md | 11 +++++++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e9e2427..226cbbc 100644 --- a/README.md +++ b/README.md @@ -686,8 +686,6 @@ s = You(debug_logger=logging.getLogger("youdotcom")) You can also enable a default debug logger by setting an environment variable `YOU_DEBUG` to true. - - # Development ## Maturity @@ -727,5 +725,3 @@ For more details on testing, see the [tests README](tests/README.md). While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation. We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release. - -### SDK Created by [Speakeasy](https://www.speakeasy.com/?utm_source=youdotcom&utm_campaign=python) diff --git a/docs/models/answerresponse.md b/docs/models/answerresponse.md index d3fbfbe..d469f56 100644 --- a/docs/models/answerresponse.md +++ b/docs/models/answerresponse.md @@ -2,11 +2,18 @@ A synthesized answer with citations and supporting search results. +## AnswerResults -## Fields +Search results grouped by result type. + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `web` | List[[models.AnswerSearchResult](../models/answersearchresult.md)] | :heavy_minus_sign: | All web search results considered during answer synthesis. | + +## AnswerResponse | Field | Type | Required | Description | |-------|------|----------|-------------| | `answer` | *str* | :heavy_check_mark: | The synthesized response with numbered inline citations that reference items in the `citations` array. | | `citations` | List[[models.AnswerCitation](../models/answercitation.md)] | :heavy_minus_sign: | The sources cited in the answer, in citation order. | -| `results` | [models.AnswerResults](../models/answerresponse.md) | :heavy_minus_sign: | Search results grouped by result type. Contains a `web` array of [AnswerSearchResult](../models/answersearchresult.md). | +| `results` | [models.AnswerResults](#answerresults) | :heavy_minus_sign: | Search results grouped by result type. | From b000d4ffd873f0bb7c3dfb75dfeda9e28d125efe Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 13:55:35 -0700 Subject: [PATCH 10/35] fix: remove stale Speakeasy regen note from search_helpers docstring The SDK is no longer generated by Speakeasy, so the "Re-apply this file after Speakeasy regen" instruction is misleading. Updated the module docstring to reflect hand-maintained status. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/search_helpers.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py index 06146a8..3d21fe7 100644 --- a/src/youdotcom/search_helpers.py +++ b/src/youdotcom/search_helpers.py @@ -1,8 +1,9 @@ """Hand-maintained search helpers targeting ``/v1/agents/search``. -This module is NOT regenerated by Speakeasy. It mirrors the generated -``search_post`` request machinery but POSTs to ``/v1/agents/search`` instead -of ``/v1/search``. The agents-search endpoint is a proxy that: +This module is hand-maintained (the SDK is no longer generated by Speakeasy). +It mirrors the ``search_post`` request machinery but POSTs to +``/v1/agents/search`` instead of ``/v1/search``. The agents-search endpoint is +a proxy that: - **With an API key** → forwards to ``/v1/search`` unrestricted (full features). - **Without a key** → free tier: IP-rate-limited, ``count`` capped at 1–50, @@ -11,9 +12,6 @@ Use this as the default search entrypoint for skills, plugins, and MCP tools. The generated ``search.unified`` / ``search_post`` remain available for callers who need the raw ``/v1/search`` endpoint. - -Re-apply this file after Speakeasy regen (precedent: ``research_helpers.py``, -``utils/security.py``). """ from __future__ import annotations From be62e04a33f8c1c7b1c2086ed0e5e75af89e2bc3 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 14:02:51 -0700 Subject: [PATCH 11/35] fix: update user-agent hook prefix from speakeasy-sdk/ to youdotcom-python-sdk/ The Speakeasy removal changed __user_agent__ but left _DEFAULT_UA_PREFIX in the hook pointing at "speakeasy-sdk/". This caused the hook to treat the new default UA as custom and pass it through without rewriting, producing "youdotcom-python-sdk 2.5.0" (space) instead of the expected "youdotcom-python-sdk/2.5.0" (slash). - _version.py: __user_agent__ = "youdotcom-python-sdk/2.5.0" - registration.py: _DEFAULT_UA_PREFIX = "youdotcom-python-sdk/" - test_user_agent_hook.py: updated test to use new prefix Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/_hooks/registration.py | 12 +++++++----- src/youdotcom/_version.py | 2 +- tests/test_user_agent_hook.py | 18 ++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/youdotcom/_hooks/registration.py b/src/youdotcom/_hooks/registration.py index fb7f679..79e4cf0 100644 --- a/src/youdotcom/_hooks/registration.py +++ b/src/youdotcom/_hooks/registration.py @@ -7,7 +7,12 @@ # Any hooks you wish to add should be registered in the init_hooks function. Feel free to define them # in this file or in separate files in the hooks folder. -_DEFAULT_UA_PREFIX = "speakeasy-sdk/" +# ponytail: the hook checks whether the configured user-agent matches the SDK +# default. If it does, the hook emits the canonical ``youdotcom-python-sdk/{version}`` +# format. If a caller overrides user_agent away from this default, the hook passes +# it through so integrations (langchain-youdotcom, youdotcom-temporal, +# n8n-nodes-youdotcom) can identify their traffic. +_DEFAULT_UA_PREFIX = "youdotcom-python-sdk/" class YDCUserAgentOverrideHook(BeforeRequestHook): @@ -15,7 +20,7 @@ class YDCUserAgentOverrideHook(BeforeRequestHook): Behaviour: - If ``sdk_configuration.user_agent`` has been overridden away from the - speakeasy-default (``speakeasy-sdk/python ...``), pass it through so + SDK default (``youdotcom-python-sdk/...``), pass it through so integrations (langchain-youdotcom, youdotcom-temporal, n8n-nodes-youdotcom) can identify their traffic. - Otherwise, emit the SDK-default ``youdotcom-python-sdk/{sdk_version}``. @@ -25,9 +30,6 @@ def before_request(self, hook_ctx: BeforeRequestContext, request: httpx.Request) sdk_version = hook_ctx.config.sdk_version configured_ua = hook_ctx.config.user_agent - # `not startswith(_DEFAULT_UA_PREFIX)` already handles the default-UA - # case (the speakeasy default always starts with the prefix), so a - # separate `configured_ua != __user_agent__` check is redundant. is_custom = bool(configured_ua) and not configured_ua.startswith(_DEFAULT_UA_PREFIX) request.headers["User-Agent"] = ( diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 654ea4e..8a5aba5 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -4,7 +4,7 @@ __title__: str = "youdotcom" __version__: str = "2.5.0" __openapi_doc_version__: str = "1.0.0" -__user_agent__: str = "youdotcom-python-sdk 2.5.0" +__user_agent__: str = "youdotcom-python-sdk/2.5.0" try: if __package__ is not None: diff --git a/tests/test_user_agent_hook.py b/tests/test_user_agent_hook.py index 768e73c..cbff3ef 100644 --- a/tests/test_user_agent_hook.py +++ b/tests/test_user_agent_hook.py @@ -1,10 +1,8 @@ """Unit tests for YDCUserAgentOverrideHook. -This hook is a hand-maintained addition (not regenerated by Speakeasy). -The CHANGELOG flags it as regen-fragile: future SDK regens will overwrite -the file and the hook MUST be re-applied. These tests ensure a regen -revert is caught by CI, mirroring how tests/test_security_env.py guards -the env-var precedence hand-edit. +This hook is a hand-maintained addition. These tests ensure the hook +correctly overrides the default SDK user-agent and passes through custom +user-agents from integrations. """ import httpx @@ -33,8 +31,8 @@ def _make_ctx(user_agent: str, sdk_version: str = __version__) -> BeforeRequestC class TestYDCUserAgentOverrideHook: - def test_default_speakeasy_ua_is_overridden(self): - """When user_agent is the speakeasy default, the hook rewrites it to youdotcom-python-sdk/{version}.""" + def test_default_ua_is_overridden(self): + """When user_agent is the SDK default, the hook rewrites it to youdotcom-python-sdk/{version}.""" hook = YDCUserAgentOverrideHook() ctx = _make_ctx(__user_agent__) request = httpx.Request("GET", "http://mock.local/test") @@ -50,10 +48,10 @@ def test_custom_user_agent_passes_through(self): hook.before_request(ctx, request) assert request.headers["User-Agent"] == custom_ua - def test_speakeasy_prefix_ua_is_overridden(self): - """A UA starting with speakeasy-sdk/ (but different from default) is still overridden.""" + def test_sdk_prefix_ua_is_overridden(self): + """A UA starting with youdotcom-python-sdk/ (but different version) is still overridden.""" hook = YDCUserAgentOverrideHook() - ctx = _make_ctx("speakeasy-sdk/python 9.9.9 custom") + ctx = _make_ctx("youdotcom-python-sdk/9.9.9 custom") request = httpx.Request("GET", "http://mock.local/test") hook.before_request(ctx, request) assert request.headers["User-Agent"] == f"youdotcom-python-sdk/{__version__}" From 1536e28095e7b8131b0a5c744ece313d26a8078a Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 14:10:31 -0700 Subject: [PATCH 12/35] =?UTF-8?q?refactor:=20remove=20YDCUserAgentOverride?= =?UTF-8?q?Hook=20=E2=80=94=20no=20longer=20needed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook existed to rewrite Speakeasy's default UA (speakeasy-sdk/python ...) to youdotcom-python-sdk/{version}. Now that __user_agent__ is already youdotcom-python-sdk/2.5.0, BaseSDK._build_request sets it directly on every request — the hook was a no-op that added per-request overhead. Integrations that need a custom UA still just set client.sdk_configuration.user_agent — BaseSDK picks it up. - Gut registration.py: init_hooks is now a no-op - Delete test_user_agent_hook.py - Update tests/README.md and CHANGELOG Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/youdotcom/_hooks/registration.py | 50 ++++-------------------- tests/README.md | 1 - tests/test_user_agent_hook.py | 57 ---------------------------- 4 files changed, 8 insertions(+), 101 deletions(-) delete mode 100644 tests/test_user_agent_hook.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 226cf95..704c67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **422 error data model**: `UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. All three 422 response shapes deserialize without crashing. Backward compatible — existing code accessing `.error` still works. - **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. +- **Removed `YDCUserAgentOverrideHook`**: The hook existed to rewrite Speakeasy's default UA (`speakeasy-sdk/python ...`) to `youdotcom-python-sdk/{version}`. Now that `__user_agent__` is already `youdotcom-python-sdk/{version}`, `BaseSDK._build_request` sets it directly — the hook was a no-op. Integrations that need a custom UA still just set `client.sdk_configuration.user_agent`. ## [2.5.0] - 2026-07-20 diff --git a/src/youdotcom/_hooks/registration.py b/src/youdotcom/_hooks/registration.py index 79e4cf0..ef98ff2 100644 --- a/src/youdotcom/_hooks/registration.py +++ b/src/youdotcom/_hooks/registration.py @@ -1,47 +1,11 @@ -from .types import Hooks, BeforeRequestHook, BeforeRequestContext -import httpx -from typing import Union +from .types import Hooks -# This file is only ever generated once on the first generation and then is free to be modified. -# Any hooks you wish to add should be registered in the init_hooks function. Feel free to define them -# in this file or in separate files in the hooks folder. - -# ponytail: the hook checks whether the configured user-agent matches the SDK -# default. If it does, the hook emits the canonical ``youdotcom-python-sdk/{version}`` -# format. If a caller overrides user_agent away from this default, the hook passes -# it through so integrations (langchain-youdotcom, youdotcom-temporal, -# n8n-nodes-youdotcom) can identify their traffic. -_DEFAULT_UA_PREFIX = "youdotcom-python-sdk/" - - -class YDCUserAgentOverrideHook(BeforeRequestHook): - """Hook that overrides the User-Agent header on every request. +def init_hooks(hooks: Hooks): + """Register SDK hooks. - Behaviour: - - If ``sdk_configuration.user_agent`` has been overridden away from the - SDK default (``youdotcom-python-sdk/...``), pass it through so - integrations (langchain-youdotcom, youdotcom-temporal, - n8n-nodes-youdotcom) can identify their traffic. - - Otherwise, emit the SDK-default ``youdotcom-python-sdk/{sdk_version}``. + The user-agent is set directly from ``sdk_configuration.user_agent`` in + ``BaseSDK._build_request`` — no hook needed. Integrations that want a + custom UA simply override ``client.sdk_configuration.user_agent``. """ - - def before_request(self, hook_ctx: BeforeRequestContext, request: httpx.Request) -> Union[httpx.Request, Exception]: - sdk_version = hook_ctx.config.sdk_version - configured_ua = hook_ctx.config.user_agent - - is_custom = bool(configured_ua) and not configured_ua.startswith(_DEFAULT_UA_PREFIX) - - request.headers["User-Agent"] = ( - configured_ua if is_custom else f"youdotcom-python-sdk/{sdk_version}" - ) - - return request - - -def init_hooks(hooks: Hooks): - # pylint: disable=unused-argument - """Add hooks by calling hooks.register{sdk_init/before_request/after_success/after_error}Hook - with an instance of a hook that implements that specific Hook interface - Hooks are registered per SDK instance, and are valid for the lifetime of the SDK instance""" - hooks.register_before_request_hook(YDCUserAgentOverrideHook()) + pass diff --git a/tests/README.md b/tests/README.md index ca38eb3..4fad06e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -58,7 +58,6 @@ pytest tests/ -v - `test_research.py` - Tests for the Research API (`/v1/research`) including background mode, output_schema, and source_control - `test_research_helpers.py` - Tests for the hand-maintained `research_helpers` module (background submission, polling, streaming, research_and_wait) - `test_security_env.py` - Tests for environment variable precedence (`YDC_API_KEY` / `YOU_API_KEY_AUTH`) -- `test_user_agent_hook.py` - Tests for the `YDCUserAgentOverrideHook` custom user-agent pass-through - `test_performance.py` - Performance/instrumentation tests measuring SDK overhead - `test_live.py` - Live API tests that run against the real You.com API (requires API key) diff --git a/tests/test_user_agent_hook.py b/tests/test_user_agent_hook.py deleted file mode 100644 index cbff3ef..0000000 --- a/tests/test_user_agent_hook.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Unit tests for YDCUserAgentOverrideHook. - -This hook is a hand-maintained addition. These tests ensure the hook -correctly overrides the default SDK user-agent and passes through custom -user-agents from integrations. -""" - -import httpx -from unittest.mock import Mock - -from youdotcom._hooks.registration import YDCUserAgentOverrideHook -from youdotcom._hooks.types import BeforeRequestContext, HookContext -from youdotcom._version import __user_agent__, __version__ - - -def _make_ctx(user_agent: str, sdk_version: str = __version__) -> BeforeRequestContext: - """Build a minimal BeforeRequestContext for hook testing.""" - config = Mock() - config.user_agent = user_agent - config.sdk_version = sdk_version - parent = HookContext( - config=config, - base_url="http://mock.local", - operation_id="test", - oauth2_scopes=None, - security_source=None, - tags=None, - extensions=None, - ) - return BeforeRequestContext(parent) - - -class TestYDCUserAgentOverrideHook: - def test_default_ua_is_overridden(self): - """When user_agent is the SDK default, the hook rewrites it to youdotcom-python-sdk/{version}.""" - hook = YDCUserAgentOverrideHook() - ctx = _make_ctx(__user_agent__) - request = httpx.Request("GET", "http://mock.local/test") - hook.before_request(ctx, request) - assert request.headers["User-Agent"] == f"youdotcom-python-sdk/{__version__}" - - def test_custom_user_agent_passes_through(self): - """When user_agent is a custom value (e.g. integration UA), the hook passes it through unchanged.""" - hook = YDCUserAgentOverrideHook() - custom_ua = "langchain-youdotcom/1.0" - ctx = _make_ctx(custom_ua) - request = httpx.Request("GET", "http://mock.local/test") - hook.before_request(ctx, request) - assert request.headers["User-Agent"] == custom_ua - - def test_sdk_prefix_ua_is_overridden(self): - """A UA starting with youdotcom-python-sdk/ (but different version) is still overridden.""" - hook = YDCUserAgentOverrideHook() - ctx = _make_ctx("youdotcom-python-sdk/9.9.9 custom") - request = httpx.Request("GET", "http://mock.local/test") - hook.before_request(ctx, request) - assert request.headers["User-Agent"] == f"youdotcom-python-sdk/{__version__}" From 3b62dd7d7b301c3ba28d6d0cb6301c5f25f609ef Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 14:57:57 -0700 Subject: [PATCH 13/35] =?UTF-8?q?fix:=20country=20normalization,=20keyless?= =?UTF-8?q?=20test=20skip,=20dead=20code=20=E2=80=94=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues found by dual ponytail + honest review: [P1] search_helpers.py: country param not upper-cased before model validation. answer.py normalizes both country and language, but search_helpers only normalized language. A caller passing country='us' would hit a pydantic ValidationError. Fixed with .upper() in both search() and search_async(). Added test_lowercase_country_is_normalized. [P1] test_live.py: module-level pytestmark skipped ALL tests when no API key was set, including the keyless search tests that exist specifically to verify the no-key free-tier path. Moved skip to per-class @requires_api_key decorator; TestLiveSearchHelpers runs without an API key. [P3] test_live.py: has_any_contents variable computed in test_search_with_livecrawl_all but never asserted — dead code. Removed. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/search_helpers.py | 4 ++-- tests/test_live.py | 35 ++++++++++++++------------------- tests/test_search_helpers.py | 5 +++++ 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py index 3d21fe7..c0e492a 100644 --- a/src/youdotcom/search_helpers.py +++ b/src/youdotcom/search_helpers.py @@ -87,7 +87,7 @@ def search( count=count, freshness=freshness, offset=offset, - country=country, + country=country.upper() if isinstance(country, str) else country, safesearch=safesearch, livecrawl=livecrawl, livecrawl_formats=utils.unmarshal( @@ -180,7 +180,7 @@ async def search_async( count=count, freshness=freshness, offset=offset, - country=country, + country=country.upper() if isinstance(country, str) else country, safesearch=safesearch, livecrawl=livecrawl, livecrawl_formats=utils.unmarshal( diff --git a/tests/test_live.py b/tests/test_live.py index 0d02268..2043551 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -54,11 +54,11 @@ ) -# Skip all tests in this file if no API key is provided. +# Skip keyed tests if no API key is provided. # Mirror the SDK's own env-var precedence (YDC_API_KEY first, then # YOU_API_KEY_AUTH as the documented 2.3.x fallback) so users on the # fallback env var don't get their live suite silently skipped. -pytestmark = pytest.mark.skipif( +requires_api_key = pytest.mark.skipif( not (os.getenv("YDC_API_KEY") or os.getenv("YOU_API_KEY_AUTH")), reason="YDC_API_KEY or YOU_API_KEY_AUTH environment variable not set" ) @@ -88,6 +88,7 @@ def you_client(api_key): ) +@requires_api_key class TestLiveSearch: """Live tests for the Search API.""" @@ -170,26 +171,9 @@ def test_search_with_livecrawl_all(self, you_client): ) assert res.results is not None - - # Both web and news should be able to have contents - has_any_contents = False - - if res.results.web: - for result in res.results.web: - if result.contents: - has_any_contents = True - break - - if res.results.news: - for news_item in res.results.news: - if news_item.contents: - has_any_contents = True - break - - # We expect at least some results to have contents with livecrawl=ALL - # (This assertion may be relaxed if the API doesn't always return contents) +@requires_api_key class TestLiveContents: """Live tests for the Contents API.""" @@ -244,6 +228,7 @@ def test_multiple_formats(self, you_client): assert len(res) > 0 +@requires_api_key class TestLiveAgents: """Live tests for the Agents API.""" @@ -280,6 +265,7 @@ def test_advanced_agent_with_research(self, you_client): assert res.output is not None +@requires_api_key class TestLiveResearch: """Live tests for the Research API (new in 2.3.0).""" @@ -341,6 +327,7 @@ def test_research_with_sources(self, you_client): assert source.url is not None +@requires_api_key class TestLiveResearchOutputSchema: """Live test for Research `output_schema` parameter (beta feature). @@ -378,6 +365,7 @@ def test_research_output_schema_structured_payload(self, you_client): assert "same_entity" in res.output.content +@requires_api_key class TestLiveResearchSourceControl: """Live test for Research `source_control` parameter (beta feature). @@ -402,6 +390,7 @@ def test_research_source_control_with_boost_domains(self, you_client): assert len(res.output.content) > 0 +@requires_api_key class TestLiveFinanceResearch: """Live tests for the Finance Research API.""" @@ -445,6 +434,7 @@ def test_finance_research_lite_effort(self, you_client): assert source.url is not None +@requires_api_key class TestLiveContentsMaxAge: """Live test for Contents `max_age` parameter. @@ -465,6 +455,7 @@ def test_contents_with_max_age(self, you_client): assert len(res) > 0 +@requires_api_key class TestLiveSearchBoostDomains: """Live test for Search `boost_domains` parameter. @@ -502,6 +493,7 @@ def test_search_post_boost_domains_list(self, you_client): _BG_TIMEOUT_S = 120.0 # generous wall-clock for LITE background tasks +@requires_api_key class TestLiveResearchBackground: """Live tests for background-mode research (POST /v1/research?background=true).""" @@ -606,6 +598,7 @@ def test_research_and_wait(self, you_client): assert "output" in result_dump +@requires_api_key class TestLiveResearchBackgroundHelpers: """Live tests for the research_helpers convenience functions.""" @@ -672,6 +665,7 @@ def test_stream_research(self, you_client): # For a live test we use a simple query and a generous but bounded timeout. # Marked slow so it can be skipped with `-m "not slow"`. # --------------------------------------------------------------------------- +@requires_api_key class TestLiveResearchFrontier: """Live tests for frontier research effort (requires background=true).""" @@ -714,6 +708,7 @@ def test_frontier_without_background_raises_422(self, you_client): # --------------------------------------------------------------------------- # Answer API (new in 2.6.0) # --------------------------------------------------------------------------- +@requires_api_key class TestLiveAnswer: """Live tests for the Answer API (POST /v1/answer). diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py index a981895..c643af3 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_helpers.py @@ -82,6 +82,11 @@ def test_lowercase_language_is_normalized(self): res = search(_sync_you(_make_handler(200)), query="python", language="en") assert isinstance(res, SearchResponse) + def test_lowercase_country_is_normalized(self): + """country='us' should be normalized to 'US' before model validation.""" + res = search(_sync_you(_make_handler(200)), query="python", country="us") + assert isinstance(res, SearchResponse) + def test_exclude_and_boost_domains_accepted(self): """exclude_domains and boost_domains should be accepted as lists.""" res = search( From 8e50eb96f9747ee737d2b10d8ddfdfd828df34ed Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 15:11:27 -0700 Subject: [PATCH 14/35] fix: address review round 3 feedback (4 of 6 comments) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - [P1] country normalization: already fixed in prior commit, confirmed - [P2] __user_agent__ now derived from resolved __version__ via f-string after the importlib.metadata lookup, so it tracks the installed version - [P2] MIGRATION.md Answer API snippet: added missing import os - [P2] README.md: replaced 'generated programmatically, changes will be overwritten' with hand-maintained language matching CONTRIBUTING.md - [P2] test_live.py: async keyless test no longer leaks a user-supplied AsyncClient — let the SDK create and close its own via async with Pushed back: - [P2] 'Mark domain lists as Optional in answerrequestbody.md': the existing searchrequestbody.md docs use the same List[*str*] notation (not Optional[List[*str*]]) for the same Optional[List[str]] fields. Our docs match the established convention; the :heavy_minus_sign: marker already communicates optionality. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- MIGRATION.md | 1 + README.md | 2 +- src/youdotcom/_version.py | 3 ++- tests/test_live.py | 4 ++-- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a5d44fd..278eaab 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -7,6 +7,7 @@ New `Answer` sub-SDK for `POST /v1/answer`: ```python +import os from youdotcom import You with You(api_key_auth=os.getenv("YDC_API_KEY")) as you: diff --git a/README.md b/README.md index 226cbbc..907eade 100644 --- a/README.md +++ b/README.md @@ -723,5 +723,5 @@ For more details on testing, see the [tests README](tests/README.md). ## Contributions -While we value open-source contributions to this SDK, this library is generated programmatically. Any manual changes added to internal files will be overwritten on the next generation. +While we value open-source contributions to this SDK, this library is hand-maintained. We welcome pull requests — see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. We look forward to hearing your feedback. Feel free to open a PR or an issue with a proof of concept and we'll do our best to include it in a future release. diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 8a5aba5..7157be8 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -4,10 +4,11 @@ __title__: str = "youdotcom" __version__: str = "2.5.0" __openapi_doc_version__: str = "1.0.0" -__user_agent__: str = "youdotcom-python-sdk/2.5.0" try: if __package__ is not None: __version__ = importlib.metadata.version(__package__) except importlib.metadata.PackageNotFoundError: pass + +__user_agent__: str = f"youdotcom-python-sdk/{__version__}" diff --git a/tests/test_live.py b/tests/test_live.py index 2043551..a8f52d6 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -825,8 +825,8 @@ def test_keyless_search_helper_with_filters(self): @pytest.mark.asyncio async def test_async_keyless_search_helper(self): """search_async() with NO API key works via the free-tier proxy.""" - you = You(async_client=httpx.AsyncClient(), timeout_ms=LIVE_TIMEOUT_MS) - with you: + you = You(timeout_ms=LIVE_TIMEOUT_MS) + async with you: res = await search_helper_async(you, query="What is machine learning?", count=3) assert res.results is not None From d5cab7ed5b8c80f7747ed7c82e49703c5bfbfea6 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 15:37:39 -0700 Subject: [PATCH 15/35] test: add user agent verification (unit + live) Unit tests (test_search_helpers.py): - test_default_user_agent_is_set: verifies the request header is youdotcom-python-sdk/{__version__} via MockTransport capture - test_custom_user_agent_passes_through: verifies that overriding sdk_configuration.user_agent puts the custom value on the wire Live test (test_live.py): - test_custom_user_agent_keyless: verifies a custom UA doesn't break the keyless search path against the real API Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/test_live.py | 10 ++++++++++ tests/test_search_helpers.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tests/test_live.py b/tests/test_live.py index a8f52d6..1813e09 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -833,6 +833,16 @@ async def test_async_keyless_search_helper(self): assert res.results.web is not None assert len(res.results.web) > 0 + def test_custom_user_agent_keyless(self): + """A custom user_agent doesn't break the keyless search path.""" + you = You(timeout_ms=LIVE_TIMEOUT_MS) + you.sdk_configuration.user_agent = "test-integration/1.0" + with you: + res = search_helper(you, query="Python programming language", count=3) + + assert res.results is not None + assert res.results.web is not None + if __name__ == "__main__": # Run with: python -m pytest tests/test_live.py -v diff --git a/tests/test_search_helpers.py b/tests/test_search_helpers.py index c643af3..07db204 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_helpers.py @@ -6,6 +6,7 @@ import pytest from youdotcom import You +from youdotcom._version import __version__ from youdotcom.errors import ( InternalServerErrorResponse, PaymentRequiredResponseError, @@ -112,6 +113,38 @@ def handler(request): assert captured["method"] == "POST" assert "/v1/agents/search" in captured["url"] + def test_default_user_agent_is_set(self): + """Default UA on the request is youdotcom-python-sdk/{version}.""" + captured: dict = {} + + def handler(request): + captured["ua"] = request.headers.get("user-agent", "") + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + search(_sync_you(handler), query="python") + assert captured["ua"] == f"youdotcom-python-sdk/{__version__}" + + def test_custom_user_agent_passes_through(self): + """A custom user_agent overrides the default on the wire.""" + captured: dict = {} + + def handler(request): + captured["ua"] = request.headers.get("user-agent", "") + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + you = You( + api_key_auth="test-key", + server_url="http://mock.local", + client=httpx.Client(transport=httpx.MockTransport(handler)), + ) + you.sdk_configuration.user_agent = "my-integration/1.0" + search(you, query="python") + assert captured["ua"] == "my-integration/1.0" + @pytest.mark.asyncio async def test_async_keyed_search_returns_search_response(self): res = await search_async(_async_you(_make_handler(200)), query="python", count=5) From 45d1da73102e518d41d376feae5104f0969e0894 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 15:52:04 -0700 Subject: [PATCH 16/35] fix: address review round 4 feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed: - [P1] MIGRATION.md: 402 snippet now self-contained with all imports (You, PaymentRequiredResponseError, search) - [P1 security] README.md: added warning that debug logs include full headers/bodies which may contain API keys - [P1] tests/README.md: updated to reflect that keyless search tests (TestLiveSearchHelpers) run without an API key by design Pushed back: - [P1] Close httpx clients in test helpers: these are MockTransport clients with no real connections. No ResourceWarnings emitted. Test hygiene only — doesn't cause test failures. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- MIGRATION.md | 19 +++++++++++-------- README.md | 2 ++ tests/README.md | 7 +++++-- 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 278eaab..0cea122 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -32,15 +32,18 @@ Requires an API key. `country` and `language` accept plain strings (e.g. `"us"`, The standalone `FreeTierLimitError` exception in `search_helpers.py` has been replaced with the first-class `PaymentRequiredResponseError` (extends `YouError`). The new error provides structured data: ```python +from youdotcom import You from youdotcom.errors import PaymentRequiredResponseError - -try: - search(you, query="test", count=100) # exceeds free tier -except PaymentRequiredResponseError as e: - print(e.data.message) # "Insufficient credits" - print(e.data.upgrade_url) # "https://you.com/platform" - print(e.data.limit) # 100 - print(e.data.reset_at) # "2026-08-05T00:00:00Z" +from youdotcom.search_helpers import search + +with You() as you: + try: + search(you, query="test", count=100) # exceeds free tier + except PaymentRequiredResponseError as e: + print(e.data.message) # "Insufficient credits" + print(e.data.upgrade_url) # "https://you.com/platform" + print(e.data.limit) # 100 + print(e.data.reset_at) # "2026-08-05T00:00:00Z" ``` ### 422/500 Error Models Expanded diff --git a/README.md b/README.md index 907eade..307dd4a 100644 --- a/README.md +++ b/README.md @@ -684,6 +684,8 @@ s = You(debug_logger=logging.getLogger("youdotcom")) ``` You can also enable a default debug logger by setting an environment variable `YOU_DEBUG` to true. + +**Warning:** Debug logs include full request headers and bodies, which may contain API keys and sensitive data. Do not enable debug logging in production or commit debug logs to version control. # Development diff --git a/tests/README.md b/tests/README.md index 4fad06e..9b33771 100644 --- a/tests/README.md +++ b/tests/README.md @@ -102,12 +102,15 @@ Tests are organized into logical classes using pytest: ### Running Live Tests -The `test_live.py` file contains tests that run against the real You.com API. These are skipped by default unless an API key is provided: +The `test_live.py` file contains tests that run against the real You.com API. Keyed tests are skipped unless an API key is provided. Keyless search tests (`TestLiveSearchHelpers`) run without an API key by default, since they verify the free-tier `/v1/agents/search` proxy: ```bash -# Run live tests with your API key +# Run live tests with your API key (enables keyed tests) YDC_API_KEY="your-api-key" pytest tests/test_live.py -v +# Run only keyless live tests (no API key needed) +pytest tests/test_live.py::TestLiveSearchHelpers -v + # Run all tests except live tests pytest tests/ --ignore=tests/test_live.py -v ``` From bf8a66dfa2d35b81c517050756889dacb27e4dfc Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 16:32:51 -0700 Subject: [PATCH 17/35] refactor: move Answer from sub-SDK to direct method on You MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit answer was a sub-SDK with a single operation (create), making the call path you.answer.create(query=...). As a direct method on You, it's now you.answer(query=...) — consistent with you.research(), you.search_post(), you.finance_research(). - Deleted src/youdotcom/answer.py (Answer class) - Moved answer()/answer_async() onto You class in sdk.py - Removed Answer from _sub_sdk_map and TYPE_CHECKING imports - Updated all tests: you.answer.create() → you.answer() - Updated docs/sdks/answer/README.md: documents you.answer() as direct method - Updated docs/sdks/you/README.md: added answer to operations list - Updated README, CHANGELOG, MIGRATION to reflect you.answer() Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- MIGRATION.md | 4 +- README.md | 2 +- docs/sdks/answer/README.md | 8 +- docs/sdks/you/README.md | 1 + src/youdotcom/answer.py | 305 ------------------------------------- src/youdotcom/sdk.py | 300 +++++++++++++++++++++++++++++++++++- tests/test_answer.py | 38 ++--- tests/test_live.py | 10 +- 9 files changed, 331 insertions(+), 339 deletions(-) delete mode 100644 src/youdotcom/answer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 704c67c..aaa2b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Answer API**: New `Answer` sub-SDK — `you.answer.create()` / `you.answer.create_async()` for `POST /v1/answer`. Returns a synthesized markdown answer with inline citations (`[[1, 2]]`), a citations array (source URLs + supporting excerpts), and web results. Accepts `query` (required), `freshness`, `country`, `language`, `include_domains`, `exclude_domains`, `boost_domains`. Requires an API key. Country and language accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase automatically. +- **Answer API**: New direct method `you.answer()` / `you.answer_async()` for `POST /v1/answer`. Returns a synthesized markdown answer with inline citations (`[[1, 2]]`), a citations array (source URLs + supporting excerpts), and web results. Accepts `query` (required), `freshness`, `country`, `language`, `include_domains`, `exclude_domains`, `boost_domains`. Requires an API key. Country and language accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase automatically. - **Keyless search helper**: `search_helpers.search()` / `search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. With no API key, runs in the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, forwards to the full search endpoint. Language strings are normalized to uppercase. - **`PaymentRequiredResponseError`**: New first-class error class for HTTP 402 responses, matching the `UpgradeRequiredResponse` schema (`error`, `message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). Shared by both search and answer 402 handlers. Replaces the previous `FreeTierLimitError`. diff --git a/MIGRATION.md b/MIGRATION.md index 0cea122..a305bfd 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -4,14 +4,14 @@ ### Answer API -New `Answer` sub-SDK for `POST /v1/answer`: +New direct method `you.answer()` for `POST /v1/answer`: ```python import os from youdotcom import You with You(api_key_auth=os.getenv("YDC_API_KEY")) as you: - res = you.answer.create(query="What causes the 2008 financial crisis?") + res = you.answer(query="What causes the 2008 financial crisis?") print(res.answer) # markdown with [[1, 2]] citations print(res.citations[0].source) # source URL print(res.results.web[0].title) # web result title diff --git a/README.md b/README.md index 307dd4a..9d1c7a1 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ with You( ### [Answer](docs/sdks/answer/README.md) -* [create](docs/sdks/answer/README.md#create) - Returns a synthesized answer with citations from web search results +* [answer](docs/sdks/answer/README.md#answer) - Returns a synthesized answer with citations from web search results ### Keyless Search diff --git a/docs/sdks/answer/README.md b/docs/sdks/answer/README.md index 8cc50c4..8b71213 100644 --- a/docs/sdks/answer/README.md +++ b/docs/sdks/answer/README.md @@ -4,11 +4,13 @@ The Answer API returns a synthesized natural-language answer with citations and the web results used to generate it. Send a `query` with optional freshness, locale, and domain controls. +Called as a direct method on the `You` client: `you.answer(query=...)`. + ### Available Operations -* [create](#create) - Returns a synthesized answer with citations from web search results +* [answer](#answer) - Returns a synthesized answer with citations from web search results -## create +## answer Returns a synthesized natural-language answer with citations and the web results used to generate it. Provide a `query` and optional freshness, locale, and domain controls. @@ -23,7 +25,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.answer.create(query="What are the main causes of the 2008 financial crisis?") + res = you.answer(query="What are the main causes of the 2008 financial crisis?") # Handle response print(res.answer) diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 00a8d5f..a27322d 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -17,6 +17,7 @@ Comprehensive API for You.com services: ### Available Operations +* [answer](#answer) - Returns a synthesized answer with citations from web search results * [search_post](#search_post) - Returns a list of unified search results from web and news sources * [research](#research) - Returns comprehensive research-grade answers with multi-step reasoning * [get_research_task](#get_research_task) - Get the status of a background research task diff --git a/src/youdotcom/answer.py b/src/youdotcom/answer.py deleted file mode 100644 index 09c605b..0000000 --- a/src/youdotcom/answer.py +++ /dev/null @@ -1,305 +0,0 @@ -from .basesdk import BaseSDK -from typing import Any, Iterable, List, Mapping, Optional, Union -from youdotcom import errors, models, utils -from youdotcom._hooks import HookContext -from youdotcom.types import OptionalNullable, UNSET -from youdotcom.utils import get_security_from_env -from youdotcom.utils.unmarshal_json_response import unmarshal_json_response - - -class Answer(BaseSDK): - def create( - self, - *, - query: str, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - country: Optional[str] = None, - language: Optional[str] = None, - include_domains: Optional[Iterable[str]] = None, - exclude_domains: Optional[Iterable[str]] = None, - boost_domains: Optional[Iterable[str]] = None, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.AnswerResponse: - r"""Returns a synthesized answer with citations from web search results. - - Provide a ``query`` and optional freshness, locale, and domain controls. - The response includes a markdown answer with inline citations, a - citations array with source URLs and supporting excerpts, and the web - results used to generate the answer. - - :param query: The search query used to retrieve relevant web results. - Max 400 characters. Search operators (``site:``, ``OR``, etc.) are - not supported. - :param freshness: Specifies the freshness of the results. One of ``day``, - ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. - :param country: A supported country code that determines the geographical - focus of the web results. - :param language: A supported BCP 47 language tag that determines the - language of the web results. - :param include_domains: Domains to exclusively include. Cannot combine - with ``exclude_domains`` or ``boost_domains``. Max 500. - :param exclude_domains: Domains to exclude. Cannot combine with - ``include_domains``. Can combine with ``boost_domains``. Max 500. - :param boost_domains: Domains to prefer in ranking. Cannot combine with - ``include_domains``. Can combine with ``exclude_domains``. Max 500. - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for - this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = self._get_url(None, None) - - body: dict = dict( - query=query, - freshness=freshness, - country=country.upper() if isinstance(country, str) else country, - language=language.upper() if isinstance(language, str) else language, - include_domains=utils.unmarshal(include_domains, Optional[List[str]]), - exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), - boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), - ) - request = models.AnswerRequestBody(**body) - - req = self._build_request( - method="POST", - path="/v1/answer", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=False, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.AnswerRequestBody - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = self.do_request( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="answer", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["answer"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.AnswerResponse, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "402", "application/json"): - response_data = unmarshal_json_response( - errors.PaymentRequiredResponseErrorData, http_res - ) - raise errors.PaymentRequiredResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) - - async def create_async( - self, - *, - query: str, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - country: Optional[str] = None, - language: Optional[str] = None, - include_domains: Optional[Iterable[str]] = None, - exclude_domains: Optional[Iterable[str]] = None, - boost_domains: Optional[Iterable[str]] = None, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.AnswerResponse: - r"""Returns a synthesized answer with citations from web search results. - - Provide a ``query`` and optional freshness, locale, and domain controls. - The response includes a markdown answer with inline citations, a - citations array with source URLs and supporting excerpts, and the web - results used to generate the answer. - - :param query: The search query used to retrieve relevant web results. - Max 400 characters. Search operators (``site:``, ``OR``, etc.) are - not supported. - :param freshness: Specifies the freshness of the results. One of ``day``, - ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. - :param country: A supported country code that determines the geographical - focus of the web results. - :param language: A supported BCP 47 language tag that determines the - language of the web results. - :param include_domains: Domains to exclusively include. Cannot combine - with ``exclude_domains`` or ``boost_domains``. Max 500. - :param exclude_domains: Domains to exclude. Cannot combine with - ``include_domains``. Can combine with ``boost_domains``. Max 500. - :param boost_domains: Domains to prefer in ranking. Cannot combine with - ``include_domains``. Can combine with ``exclude_domains``. Max 500. - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for - this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = self._get_url(None, None) - - body: dict = dict( - query=query, - freshness=freshness, - country=country.upper() if isinstance(country, str) else country, - language=language.upper() if isinstance(language, str) else language, - include_domains=utils.unmarshal(include_domains, Optional[List[str]]), - exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), - boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), - ) - request = models.AnswerRequestBody(**body) - - req = self._build_request_async( - method="POST", - path="/v1/answer", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=False, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.AnswerRequestBody - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = await self.do_request_async( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="answer", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["answer"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.AnswerResponse, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "402", "application/json"): - response_data = unmarshal_json_response( - errors.PaymentRequiredResponseErrorData, http_res - ) - raise errors.PaymentRequiredResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index c0475e6..675cb35 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -29,7 +29,6 @@ if TYPE_CHECKING: from youdotcom.agents import Agents - from youdotcom.answer import Answer from youdotcom.contents_sdk import ContentsSDK from youdotcom.search import Search @@ -42,6 +41,7 @@ class You(BaseSDK): Finance-focused multi-step research with competitive accuracy at same price points and latencies as the Research API Comprehensive API for You.com services: - **Agents API**: Execute queries using Express, Advanced, and Custom AI agents + - **Answer API**: Get synthesized, citation-backed answers grounded in real-time web results - **Research API**: In-depth, multi-step research with citations and sources - **Finance Research API**: Finance-focused multi-step research with citations and sources - **Search API**: Get search results from web and news sources @@ -49,12 +49,10 @@ class You(BaseSDK): """ agents: "Agents" - answer: "Answer" search: "Search" contents: "ContentsSDK" _sub_sdk_map = { "agents": ("youdotcom.agents", "Agents"), - "answer": ("youdotcom.answer", "Answer"), "search": ("youdotcom.search", "Search"), "contents": ("youdotcom.contents_sdk", "ContentsSDK"), } @@ -212,6 +210,302 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): await self.sdk_configuration.async_client.aclose() self.sdk_configuration.async_client = None + def answer( + self, + *, + query: str, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + country: Optional[str] = None, + language: Optional[str] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.AnswerResponse: + r"""Returns a synthesized answer with citations from web search results. + + Provide a ``query`` and optional freshness, locale, and domain controls. + The response includes a markdown answer with inline citations, a + citations array with source URLs and supporting excerpts, and the web + results used to generate the answer. + + :param query: The search query used to retrieve relevant web results. + Max 400 characters. Search operators (``site:``, ``OR``, etc.) are + not supported. + :param freshness: Specifies the freshness of the results. One of ``day``, + ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. + :param country: A supported country code that determines the geographical + focus of the web results. + :param language: A supported BCP 47 language tag that determines the + language of the web results. + :param include_domains: Domains to exclusively include. Cannot combine + with ``exclude_domains`` or ``boost_domains``. Max 500. + :param exclude_domains: Domains to exclude. Cannot combine with + ``include_domains``. Can combine with ``boost_domains``. Max 500. + :param boost_domains: Domains to prefer in ranking. Cannot combine with + ``include_domains``. Can combine with ``exclude_domains``. Max 500. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for + this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + body: dict = dict( + query=query, + freshness=freshness, + country=country.upper() if isinstance(country, str) else country, + language=language.upper() if isinstance(language, str) else language, + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + ) + request = models.AnswerRequestBody(**body) + + req = self._build_request( + method="POST", + path="/v1/answer", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AnswerRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="answer", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["answer"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.AnswerResponse, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + async def answer_async( + self, + *, + query: str, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + country: Optional[str] = None, + language: Optional[str] = None, + include_domains: Optional[Iterable[str]] = None, + exclude_domains: Optional[Iterable[str]] = None, + boost_domains: Optional[Iterable[str]] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.AnswerResponse: + r"""Returns a synthesized answer with citations from web search results. + + Provide a ``query`` and optional freshness, locale, and domain controls. + The response includes a markdown answer with inline citations, a + citations array with source URLs and supporting excerpts, and the web + results used to generate the answer. + + :param query: The search query used to retrieve relevant web results. + Max 400 characters. Search operators (``site:``, ``OR``, etc.) are + not supported. + :param freshness: Specifies the freshness of the results. One of ``day``, + ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. + :param country: A supported country code that determines the geographical + focus of the web results. + :param language: A supported BCP 47 language tag that determines the + language of the web results. + :param include_domains: Domains to exclusively include. Cannot combine + with ``exclude_domains`` or ``boost_domains``. Max 500. + :param exclude_domains: Domains to exclude. Cannot combine with + ``include_domains``. Can combine with ``boost_domains``. Max 500. + :param boost_domains: Domains to prefer in ranking. Cannot combine with + ``include_domains``. Can combine with ``exclude_domains``. Max 500. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for + this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + body: dict = dict( + query=query, + freshness=freshness, + country=country.upper() if isinstance(country, str) else country, + language=language.upper() if isinstance(language, str) else language, + include_domains=utils.unmarshal(include_domains, Optional[List[str]]), + exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), + boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), + ) + request = models.AnswerRequestBody(**body) + + req = self._build_request_async( + method="POST", + path="/v1/answer", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=False, + user_agent_header="user-agent", + accept_header_value="application/json", + http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AnswerRequestBody + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="answer", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["answer"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(models.AnswerResponse, http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.UnauthorizedResponseErrorData, http_res + ) + raise errors.UnauthorizedResponseError(response_data, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ForbiddenResponseErrorData, http_res + ) + raise errors.ForbiddenResponseError(response_data, http_res) + if utils.match_response(http_res, "422", "application/json"): + response_data = unmarshal_json_response( + errors.UnprocessableEntityResponseErrorData, http_res + ) + raise errors.UnprocessableEntityResponseError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.InternalServerErrorResponseData, http_res + ) + raise errors.InternalServerErrorResponse(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + def search_post( self, *, diff --git a/tests/test_answer.py b/tests/test_answer.py index 0842e25..fdcc863 100644 --- a/tests/test_answer.py +++ b/tests/test_answer.py @@ -64,7 +64,7 @@ def _async_you(handler, *, api_key: str | None = "test-key"): class TestAnswerSuccess: def test_returns_answer_response(self): - res = _sync_you(_make_handler(200)).answer.create(query="quantum computing 2025") + res = _sync_you(_make_handler(200)).answer(query="quantum computing 2025") assert isinstance(res, AnswerResponse) assert "Quantum computing" in res.answer assert len(res.citations) == 2 @@ -87,7 +87,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY ) - _sync_you(handler).answer.create(query="test") + _sync_you(handler).answer(query="test") assert captured["method"] == "POST" assert "/v1/answer" in captured["url"] @@ -100,7 +100,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY ) - _sync_you(handler).answer.create( + _sync_you(handler).answer( query="test", include_domains=["nature.com", "science.org"], country="US", @@ -120,7 +120,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY ) - _sync_you(handler).answer.create( + _sync_you(handler).answer( query="test", exclude_domains=["spam.com"], boost_domains=["reuters.com"], @@ -144,7 +144,7 @@ def handler(request): client=httpx.Client(transport=httpx.MockTransport(handler)), api_key_auth="test-key", ) - you.answer.create(query="test") + you.answer(query="test") assert "http://custom.local" in captured["url"] assert "/v1/answer" in captured["url"] @@ -158,7 +158,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY ) - _sync_you(handler).answer.create(query="test", language="en", country="us") + _sync_you(handler).answer(query="test", language="en", country="us") assert captured["body"]["language"] == "EN" assert captured["body"]["country"] == "US" @@ -171,7 +171,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY ) - _sync_you(handler).answer.create(query="test") + _sync_you(handler).answer(query="test") body = captured["body"] assert "freshness" not in body assert "country" not in body @@ -180,7 +180,7 @@ def handler(request): @pytest.mark.asyncio async def test_async_returns_answer_response(self): - res = await _async_you(_make_handler(200)).answer.create_async(query="quantum") + res = await _async_you(_make_handler(200)).answer_async(query="quantum") assert isinstance(res, AnswerResponse) assert len(res.citations) == 2 assert len(res.results.web) == 2 @@ -194,7 +194,7 @@ def test_402_raises_payment_required_error(self): "upgrade_url": "https://you.com/platform", }) with pytest.raises(PaymentRequiredResponseError) as exc_info: - _sync_you(_make_handler(402, body)).answer.create(query="test") + _sync_you(_make_handler(402, body)).answer(query="test") assert exc_info.value.status_code == 402 assert exc_info.value.data.message == "Insufficient credits" assert exc_info.value.data.upgrade_url == "https://you.com/platform" @@ -211,7 +211,7 @@ def test_402_with_usage_fields(self): "reset_at": "2026-08-05T00:00:00Z", }) with pytest.raises(PaymentRequiredResponseError) as exc_info: - _sync_you(_make_handler(402, body)).answer.create(query="test") + _sync_you(_make_handler(402, body)).answer(query="test") assert exc_info.value.data.limit == 100 assert exc_info.value.data.used == 100 assert exc_info.value.data.period == "day" @@ -220,17 +220,17 @@ def test_402_with_usage_fields(self): def test_401_raises_unauthorized_error(self): body = json.dumps({"detail": "Invalid or expired API key"}) with pytest.raises(UnauthorizedResponseError): - _sync_you(_make_handler(401, body), api_key="bad-key").answer.create(query="test") + _sync_you(_make_handler(401, body), api_key="bad-key").answer(query="test") def test_403_raises_forbidden_error(self): body = json.dumps({"detail": "Missing required scopes"}) with pytest.raises(ForbiddenResponseError): - _sync_you(_make_handler(403, body)).answer.create(query="test") + _sync_you(_make_handler(403, body)).answer(query="test") def test_422_raises_unprocessable_entity_error(self): body = json.dumps({"detail": [{"type": "missing", "loc": ["body", "query"], "msg": "Field required"}]}) with pytest.raises(UnprocessableEntityResponseError) as exc_info: - _sync_you(_make_handler(422, body)).answer.create(query="") + _sync_you(_make_handler(422, body)).answer(query="") # FastAPI validation format: detail array assert exc_info.value.data.detail is not None assert exc_info.value.data.detail[0]["type"] == "missing" @@ -239,7 +239,7 @@ def test_422_json_api_format(self): """422 in JSON:API format {errors: [{status, code, title, detail}]}.""" body = json.dumps({"errors": [{"status": "422", "code": "unprocessable_entity", "title": "Unprocessable Entity", "detail": "invalid request parameter(s)"}]}) with pytest.raises(UnprocessableEntityResponseError) as exc_info: - _sync_you(_make_handler(422, body)).answer.create(query="") + _sync_you(_make_handler(422, body)).answer(query="") assert exc_info.value.data.errors is not None assert exc_info.value.data.errors[0]["code"] == "unprocessable_entity" @@ -247,21 +247,21 @@ def test_422_search_spec_format(self): """422 in search spec format {error: string}.""" body = json.dumps({"error": "invalid request parameter(s)"}) with pytest.raises(UnprocessableEntityResponseError) as exc_info: - _sync_you(_make_handler(422, body)).answer.create(query="") + _sync_you(_make_handler(422, body)).answer(query="") assert exc_info.value.data.error == "invalid request parameter(s)" def test_500_with_json_api_errors(self): """500 in JSON:API format {errors: [...]}.""" body = json.dumps({"errors": [{"status": "500", "code": "internal_server_error", "title": "Internal Server Error"}]}) with pytest.raises(InternalServerErrorResponse) as exc_info: - _sync_you(_make_handler(500, body)).answer.create(query="test") + _sync_you(_make_handler(500, body)).answer(query="test") assert exc_info.value.data.errors is not None assert exc_info.value.data.errors[0]["code"] == "internal_server_error" def test_4xx_fallback_raises_default_error(self): body = json.dumps({"detail": "rate limited"}) with pytest.raises(YouDefaultError): - _sync_you(_make_handler(429, body)).answer.create(query="test") + _sync_you(_make_handler(429, body)).answer(query="test") @pytest.mark.asyncio async def test_async_402_raises_payment_required_error(self): @@ -271,7 +271,7 @@ async def test_async_402_raises_payment_required_error(self): "upgrade_url": "https://you.com/platform", }) with pytest.raises(PaymentRequiredResponseError) as exc_info: - await _async_you(_make_handler(402, body)).answer.create_async(query="test") + await _async_you(_make_handler(402, body)).answer_async(query="test") assert exc_info.value.status_code == 402 assert exc_info.value.data.error == "payment_required" @@ -279,4 +279,4 @@ async def test_async_402_raises_payment_required_error(self): async def test_async_500_raises_internal_server_error(self): body = json.dumps({"detail": "internal server error"}) with pytest.raises(InternalServerErrorResponse): - await _async_you(_make_handler(500, body)).answer.create_async(query="test") + await _async_you(_make_handler(500, body)).answer_async(query="test") diff --git a/tests/test_live.py b/tests/test_live.py index 1813e09..6dae64f 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -719,7 +719,7 @@ class TestLiveAnswer: def test_basic_answer(self, you_client): """Test basic answer query returns AnswerResponse with answer + citations.""" with you_client as you: - res = you.answer.create(query="What is the capital of France?") + res = you.answer(query="What is the capital of France?") assert isinstance(res, AnswerResponse) assert len(res.answer) > 0 @@ -737,7 +737,7 @@ def test_basic_answer(self, you_client): def test_answer_with_freshness(self, you_client): """Test answer with freshness filter.""" with you_client as you: - res = you.answer.create( + res = you.answer( query="Latest AI developments", freshness="week", ) @@ -748,7 +748,7 @@ def test_answer_with_freshness(self, you_client): def test_answer_with_country(self, you_client): """Test answer with country filter.""" with you_client as you: - res = you.answer.create( + res = you.answer( query="Best restaurants in London", country=Country.GB, ) @@ -759,7 +759,7 @@ def test_answer_with_country(self, you_client): def test_answer_with_boost_domains(self, you_client): """Test answer with boost_domains (can combine with exclude, not include).""" with you_client as you: - res = you.answer.create( + res = you.answer( query="Python type hints", boost_domains=["python.org", "docs.python.org"], ) @@ -771,7 +771,7 @@ def test_answer_with_boost_domains(self, you_client): async def test_async_answer(self, you_client): """Test async answer.create_async().""" with you_client as you: - res = await you.answer.create_async(query="What is 2+2?") + res = await you.answer_async(query="What is 2+2?") assert isinstance(res, AnswerResponse) assert len(res.answer) > 0 From 5c945e3229a9530be2935c285df549371aaf4f1c Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 16:36:21 -0700 Subject: [PATCH 18/35] fix: correct changelog wording about search endpoint defaults The keyless proxy at /v1/agents/search is used by search_helpers, not by the generated search.unified/search_post methods which target /v1/search. Clarified the distinction. Pushed back on [P1 security] redact debug logs: pre-existing code in basesdk.py not introduced by this PR. README warning already added. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aaa2b3e..8fb187a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The keyless search proxy at `api.you.com/v1/agents/search` is now the default for all search operations. +- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The `search_helpers` module targets the keyless-capable proxy at `api.you.com/v1/agents/search`; the generated `search.unified` / `search_post` methods target `/v1/search` on the same host. - **422 error data model**: `UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. All three 422 response shapes deserialize without crashing. Backward compatible — existing code accessing `.error` still works. - **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. From cab318f78e5eaa2827b09f8183cc2f353d2f13ee Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 17:11:24 -0700 Subject: [PATCH 19/35] feat: add direct methods on You to replace sub-SDKs, deprecate sub-SDK accessors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added direct methods on You that replace the single-operation sub-SDK pattern: - you.create_run() / you.create_run_async() → replaces you.agents.runs.create() - you.search_unified() / you.search_unified_async() → replaces you.search.unified() - you.generate_contents() / you.generate_contents_async() → replaces you.contents.generate() The direct methods delegate to the existing sub-SDK instances internally, so there's one implementation. The sub-SDK accessors (you.agents, you.search, you.contents) still work but emit DeprecationWarning and will be removed in a future major version. Updated: README, CHANGELOG, MIGRATION (with migration table), docs/sdks/you/ README.md, PR description. 11 new tests: 7 delegation tests (verify params pass through, endpoint correct, async works) + 4 deprecation tests (warning emitted, sub-SDK still functional after warning). 49 unit tests total, all pass. mypy clean. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + MIGRATION.md | 15 +++ README.md | 11 +- docs/sdks/you/README.md | 3 + src/youdotcom/sdk.py | 254 +++++++++++++++++++++++++++++++++++ tests/test_direct_methods.py | 198 +++++++++++++++++++++++++++ 6 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 tests/test_direct_methods.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb187a..0041f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. - **Removed `YDCUserAgentOverrideHook`**: The hook existed to rewrite Speakeasy's default UA (`speakeasy-sdk/python ...`) to `youdotcom-python-sdk/{version}`. Now that `__user_agent__` is already `youdotcom-python-sdk/{version}`, `BaseSDK._build_request` sets it directly — the hook was a no-op. Integrations that need a custom UA still just set `client.sdk_configuration.user_agent`. +- **Direct methods replacing sub-SDKs**: Added `you.create_run()`, `you.search_unified()`, `you.generate_contents()` (plus async variants) as direct methods on `You`, replacing the `you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()` sub-SDK paths. The sub-SDK accessors (`you.agents`, `you.search`, `you.contents`) still work but emit `DeprecationWarning` and will be removed in a future major version. ## [2.5.0] - 2026-07-20 diff --git a/MIGRATION.md b/MIGRATION.md index a305bfd..15d9e66 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -54,6 +54,21 @@ with You() as you: The SDK is now hand-maintained. All "Code generated by Speakeasy — DO NOT EDIT" disclaimers have been removed. The `__gen_version__` / `SPEAKEASY_GENERATOR_VERSION` exports have been removed. +### Sub-SDK Deprecation + +The sub-SDK access patterns (`you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()`) are deprecated. Direct methods are now available on `You`: + +| Old (deprecated) | New | +|------------------|-----| +| `you.agents.runs.create(request=...)` | `you.create_run(request=...)` | +| `you.agents.runs.create_async(request=...)` | `you.create_run_async(request=...)` | +| `you.search.unified(query=...)` | `you.search_unified(query=...)` | +| `you.search.unified_async(query=...)` | `you.search_unified_async(query=...)` | +| `you.contents.generate(urls=...)` | `you.generate_contents(urls=...)` | +| `you.contents.generate_async(urls=...)` | `you.generate_contents_async(urls=...)` | + +Accessing `you.agents`, `you.search`, or `you.contents` still works but emits `DeprecationWarning`. The sub-SDKs will be removed in a future major version. + ## 2.4.0 → 2.5.0 ### New `frontier` Research Effort Tier diff --git a/README.md b/README.md index 9d1c7a1..fdab946 100644 --- a/README.md +++ b/README.md @@ -234,15 +234,20 @@ with You( * [stream_research_task](docs/sdks/you/README.md#stream_research_task) - Stream updates for a background research task * [finance_research](docs/sdks/you/README.md#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning -### [Agents.Runs](docs/sdks/runs/README.md) +* [answer](docs/sdks/answer/README.md#answer) - Returns a synthesized answer with citations from web search results +* [create_run](docs/sdks/you/README.md#create_run) - Run an Agent +* [search_unified](docs/sdks/you/README.md#search_unified) - Returns a list of unified search results from web and news sources +* [generate_contents](docs/sdks/you/README.md#generate_contents) - Returns the content of the web pages + +### [Agents.Runs](docs/sdks/runs/README.md) (deprecated — use `you.create_run()`) * [create](docs/sdks/runs/README.md#create) - Run an Agent -### [Contents](docs/sdks/contentssdk/README.md) +### [Contents](docs/sdks/contentssdk/README.md) (deprecated — use `you.generate_contents()`) * [generate](docs/sdks/contentssdk/README.md#generate) - Returns the content of the web pages -### [Search](docs/sdks/search/README.md) +### [Search](docs/sdks/search/README.md) (deprecated — use `you.search_unified()`) * [unified](docs/sdks/search/README.md#unified) - Returns a list of unified search results from web and news sources diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index a27322d..67b9a80 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -18,6 +18,9 @@ Comprehensive API for You.com services: ### Available Operations * [answer](#answer) - Returns a synthesized answer with citations from web search results +* [create_run](#create_run) - Run an Agent +* [search_unified](#search_unified) - Returns a list of unified search results from web and news sources +* [generate_contents](#generate_contents) - Returns the content of the web pages * [search_post](#search_post) - Returns a list of unified search results from web and news sources * [research](#research) - Returns comprehensive research-grade answers with multi-step reasoning * [get_research_task](#get_research_task) - Get the status of a background research task diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 675cb35..c949b3d 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -8,6 +8,7 @@ import httpx import importlib import sys +import warnings from typing import ( Any, Callable, @@ -163,6 +164,18 @@ def dynamic_import(self, modname, retries=3): def __getattr__(self, name: str): if name in self._sub_sdk_map: + _DEPRECATED_SUB_SDKS = { + "agents": "you.create_run()", + "search": "you.search_unified()", + "contents": "you.generate_contents()", + } + if name in _DEPRECATED_SUB_SDKS: + warnings.warn( + f"you.{name} is deprecated and will be removed in a future major version. " + f"Use {_DEPRECATED_SUB_SDKS[name]} instead.", + DeprecationWarning, + stacklevel=2, + ) module_path, class_name = self._sub_sdk_map[name] try: module = self.dynamic_import(module_path) @@ -506,6 +519,247 @@ async def answer_async( raise errors.YouDefaultError("Unexpected response received", http_res) + def _get_sub_sdk(self, name: str): + """Access a sub-SDK without triggering the deprecation warning.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + return getattr(self, name) + + def create_run( + self, + *, + request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Union[ + models.AgentRunsBatchResponse, + eventstreaming.EventStream[models.AgentRunsStreamingResponse], + ]: + r"""Run an Agent. + + Direct method replacing ``you.agents.runs.create()``. + + :param request: The request object to send. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + return self._get_sub_sdk("agents").runs.create( + request=request, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + async def create_run_async( + self, + *, + request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> Union[ + models.AgentRunsBatchResponse, + eventstreaming.EventStreamAsync[models.AgentRunsStreamingResponse], + ]: + r"""Run an Agent (async). + + Direct method replacing ``you.agents.runs.create_async()``. + + :param request: The request object to send. + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout in milliseconds + :param http_headers: Additional headers to set or replace on requests. + """ + return await self._get_sub_sdk("agents").runs.create_async( + request=request, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + def search_unified( + self, + *, + query: str, + count: Optional[int] = 10, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + offset: Optional[int] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = models.Language.EN, + safesearch: Optional[models.SafeSearch] = None, + livecrawl: Optional[models.LiveCrawl] = None, + livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, + include_domains: Optional[str] = None, + exclude_domains: Optional[str] = None, + boost_domains: Optional[str] = None, + crawl_timeout: Optional[int] = 10, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.SearchResponse: + r"""Returns a list of unified search results from web and news sources. + + Direct method replacing ``you.search.unified()``. + + :param query: The search query. + :param count: Max results per section. + :param freshness: ``day``, ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. + :param offset: Pagination offset. + :param country: Country code for geographical focus. + :param language: BCP 47 language code (default ``EN``). + :param safesearch: ``strict``, ``moderate``, or ``off``. + :param livecrawl: ``web``, ``news``, or ``all``. + :param livecrawl_formats: ``["html"]``, ``["markdown"]``, or both. + :param include_domains: Comma-separated domains to restrict results to. + :param exclude_domains: Comma-separated domains to exclude. + :param boost_domains: Comma-separated domains to boost in ranking. + :param crawl_timeout: Max seconds to wait for livecrawl (1-60, default 10). + :param retries: Override the default retry configuration. + :param server_url: Override the default server URL. + :param timeout_ms: Override the request timeout in milliseconds. + :param http_headers: Additional headers to set or replace. + """ + return self._get_sub_sdk("search").unified( + query=query, + count=count, + freshness=freshness, + offset=offset, + country=country, + language=language, + safesearch=safesearch, + livecrawl=livecrawl, + livecrawl_formats=livecrawl_formats, + include_domains=include_domains, + exclude_domains=exclude_domains, + boost_domains=boost_domains, + crawl_timeout=crawl_timeout, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + async def search_unified_async( + self, + *, + query: str, + count: Optional[int] = 10, + freshness: Optional[ + Union[models.FreshnessValue, models.FreshnessValueTypedDict] + ] = None, + offset: Optional[int] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = models.Language.EN, + safesearch: Optional[models.SafeSearch] = None, + livecrawl: Optional[models.LiveCrawl] = None, + livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, + include_domains: Optional[str] = None, + exclude_domains: Optional[str] = None, + boost_domains: Optional[str] = None, + crawl_timeout: Optional[int] = 10, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> models.SearchResponse: + r"""Returns a list of unified search results from web and news sources (async). + + Direct method replacing ``you.search.unified_async()``. + """ + return await self._get_sub_sdk("search").unified_async( + query=query, + count=count, + freshness=freshness, + offset=offset, + country=country, + language=language, + safesearch=safesearch, + livecrawl=livecrawl, + livecrawl_formats=livecrawl_formats, + include_domains=include_domains, + exclude_domains=exclude_domains, + boost_domains=boost_domains, + crawl_timeout=crawl_timeout, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + def generate_contents( + self, + *, + urls: Optional[Iterable[str]] = None, + formats: Optional[Iterable[models.ContentsFormats]] = None, + crawl_timeout: Optional[int] = 10, + max_age: OptionalNullable[int] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> List[models.ContentsResponse]: + r"""Returns the content of the web pages. + + Direct method replacing ``you.contents.generate()``. + + :param urls: Array of URLs to fetch the contents from. + :param formats: Array of content formats to return (``html``, ``markdown``, ``metadata``). + :param crawl_timeout: Maximum time in seconds to wait for page content (1-60, default 10). + :param max_age: Maximum allowed age of cached content in seconds. + :param retries: Override the default retry configuration. + :param server_url: Override the default server URL. + :param timeout_ms: Override the request timeout in milliseconds. + :param http_headers: Additional headers to set or replace. + """ + return self._get_sub_sdk("contents").generate( + urls=urls, + formats=formats, + crawl_timeout=crawl_timeout, + max_age=max_age, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + + async def generate_contents_async( + self, + *, + urls: Optional[Iterable[str]] = None, + formats: Optional[Iterable[models.ContentsFormats]] = None, + crawl_timeout: Optional[int] = 10, + max_age: OptionalNullable[int] = None, + retries: OptionalNullable[utils.RetryConfig] = UNSET, + server_url: Optional[str] = None, + timeout_ms: Optional[int] = None, + http_headers: Optional[Mapping[str, str]] = None, + ) -> List[models.ContentsResponse]: + r"""Returns the content of the web pages (async). + + Direct method replacing ``you.contents.generate_async()``. + """ + return await self._get_sub_sdk("contents").generate_async( + urls=urls, + formats=formats, + crawl_timeout=crawl_timeout, + max_age=max_age, + retries=retries, + server_url=server_url, + timeout_ms=timeout_ms, + http_headers=http_headers, + ) + def search_post( self, *, diff --git a/tests/test_direct_methods.py b/tests/test_direct_methods.py new file mode 100644 index 0000000..e646f59 --- /dev/null +++ b/tests/test_direct_methods.py @@ -0,0 +1,198 @@ +"""Tests for direct methods on You that replace sub-SDK access patterns. + +Verifies that you.create_run(), you.search_unified(), you.generate_contents() +work identically to the sub-SDK paths, and that sub-SDK access emits +DeprecationWarning. +""" + +import json +import warnings + +import httpx +import pytest + +from youdotcom import You +from youdotcom.models import ( + AgentRunsBatchResponse, + ContentsResponse, + SearchResponse, +) + + +_SEARCH_BODY = json.dumps( + {"results": {"web": [{"title": "Test", "url": "https://example.com"}]}} +) +_CONTENTS_BODY = json.dumps( + [{"url": "https://example.com", "html": "

Hello

"}] +) +_RUNS_BODY = json.dumps( + { + "agent": "express", + "input": [{"role": "user", "content": "Hello"}], + "output": [{"type": "message.answer", "text": "Hello world"}], + } +) + + +def _make_client(handler, *, api_key="test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "client": httpx.Client(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +class TestDirectMethodDelegation: + def test_search_unified_delegates_to_search_unified(self): + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + res = _make_client(handler).search_unified(query="python") + assert isinstance(res, SearchResponse) + assert "/v1/search" in captured["url"] + + def test_search_unified_passes_all_params(self): + captured: dict = {} + + def handler(request): + captured["params"] = dict(request.url.params) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + _make_client(handler).search_unified( + query="ai news", + count=5, + freshness="week", + country="US", + include_domains="nytimes.com", + ) + assert captured["params"]["query"] == "ai news" + assert captured["params"]["count"] == "5" + assert captured["params"]["freshness"] == "week" + + def test_generate_contents_delegates_to_contents_generate(self): + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY + ) + + res = _make_client(handler).generate_contents(urls=["https://example.com"]) + assert isinstance(res, list) + assert isinstance(res[0], ContentsResponse) + assert "/v1/contents" in captured["url"] + + def test_create_run_delegates_to_agents_runs_create(self): + captured: dict = {} + + def handler(request): + captured["url"] = str(request.url) + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_RUNS_BODY + ) + + res = _make_client(handler).create_run( + request={"agent": "express", "input": "Hello"} + ) + assert isinstance(res, AgentRunsBatchResponse) + assert "/v1/agents/runs" in captured["url"] + assert captured["body"]["agent"] == "express" + + @pytest.mark.asyncio + async def test_search_unified_async_delegates(self): + def handler(request): + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + ) + + you = You( + api_key_auth="test-key", + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + res = await you.search_unified_async(query="python") + assert isinstance(res, SearchResponse) + + @pytest.mark.asyncio + async def test_generate_contents_async_delegates(self): + def handler(request): + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY + ) + + you = You( + api_key_auth="test-key", + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + res = await you.generate_contents_async(urls=["https://example.com"]) + assert isinstance(res, list) + assert isinstance(res[0], ContentsResponse) + + @pytest.mark.asyncio + async def test_create_run_async_delegates(self): + def handler(request): + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_RUNS_BODY + ) + + you = You( + api_key_auth="test-key", + server_url="http://mock.local", + async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ) + res = await you.create_run_async( + request={"agent": "express", "input": "Hello"} + ) + assert isinstance(res, AgentRunsBatchResponse) + + +class TestDeprecationWarnings: + def test_search_access_emits_deprecation_warning(self): + you = _make_client(lambda req: httpx.Response(200)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _ = you.search + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "you.search_unified()" in str(w[0].message) + + def test_agents_access_emits_deprecation_warning(self): + you = _make_client(lambda req: httpx.Response(200)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _ = you.agents + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "you.create_run()" in str(w[0].message) + + def test_contents_access_emits_deprecation_warning(self): + you = _make_client(lambda req: httpx.Response(200)) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + _ = you.contents + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "you.generate_contents()" in str(w[0].message) + + def test_sub_sdk_still_works_after_warning(self): + """Sub-SDK access still returns a working instance despite the warning.""" + you = _make_client(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + )) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + search_sdk = you.search + res = search_sdk.unified(query="test") + assert isinstance(res, SearchResponse) From 27bfc07821ecdc23a76f1c78cf440185cb6a55db Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 17:15:00 -0700 Subject: [PATCH 20/35] =?UTF-8?q?fix:=20dev=20install=20instructions,=20st?= =?UTF-8?q?ale=20ydc-index.io=20in=20README=20=E2=80=94=20review=20feedbac?= =?UTF-8?q?k?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [P1] CONTRIBUTING.md: replaced pip install -e .[dev] with correct install (pip install -e . + pip install dev tools, or uv sync --dev) since pyproject.toml uses [dependency-groups] not [optional-dependencies] - [P3] README.md: updated stale ydc-index.io example to api.you.com Pushed back: - [P2] Eager body materialization in debug logs: pre-existing code in basesdk.py, not introduced by this PR Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CONTRIBUTING.md | 9 ++++++++- README.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3927f62..b376021 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,7 +27,14 @@ If you encounter any bugs or have suggestions for improvements, please open an i ```bash python -m venv .venv source .venv/bin/activate -pip install -e ".[dev]" +pip install -e . +pip install mypy pylint pyright pytest pytest-asyncio +``` + +Or with [uv](https://docs.astral.sh/uv/): + +```bash +uv sync --dev ``` ## Contact diff --git a/README.md b/README.md index fdab946..a62bb92 100644 --- a/README.md +++ b/README.md @@ -557,7 +557,7 @@ with You( ], boost_domains=[ "nytimes.com", "wired.com", - ], crawl_timeout=10, server_url="https://ydc-index.io") + ], crawl_timeout=10, server_url="https://api.you.com") # Handle response print(res) From 351d0e029f8ea3792006f40210139c7e3b77a22c Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 17:57:45 -0700 Subject: [PATCH 21/35] Remove sub-SDKs, direct methods on You (breaking change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-SDKs (Agents, Search, ContentsSDK) removed entirely. All operations are now direct methods on the You class: - you.agents.runs.create() → you.agents() - you.search_post() → you.search() (POST /v1/search) - you.contents.generate() → you.contents() - you.search.unified() (GET /v1/search) → removed Deleted: agents.py, runs.py, search.py, contents_sdk.py Removed: _sub_sdk_map, __getattr__, __dir__, dynamic_import, _get_sub_sdk Updated all tests, docs (README, CHANGELOG, MIGRATION, docs/sdks/), and PR description. 54 unit tests pass, mypy clean (108 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 21 +- MIGRATION.md | 27 +- README.md | 42 +-- docs/sdks/contentssdk/README.md | 7 + docs/sdks/runs/README.md | 7 + docs/sdks/search/README.md | 7 + docs/sdks/you/README.md | 23 +- src/youdotcom/agents.py | 20 - src/youdotcom/contents_sdk.py | 239 ------------ src/youdotcom/runs.py | 293 -------------- src/youdotcom/sdk.py | 649 +++++++++++++++++++++----------- src/youdotcom/search.py | 325 ---------------- tests/PERFORMANCE_TESTING.md | 2 +- tests/test_contents.py | 24 +- tests/test_direct_methods.py | 219 ++++++----- tests/test_live.py | 26 +- tests/test_performance.py | 90 ++--- tests/test_runs.py | 26 +- tests/test_search.py | 207 +--------- 19 files changed, 725 insertions(+), 1529 deletions(-) delete mode 100644 src/youdotcom/agents.py delete mode 100644 src/youdotcom/contents_sdk.py delete mode 100644 src/youdotcom/runs.py delete mode 100644 src/youdotcom/search.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0041f94..b75052e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Breaking Changes + +- **Sub-SDK Removal**: The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) have been removed. Methods are now direct on the `You` class. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. `you.search.unified()` (GET `/v1/search`) has been removed; use `you.search()` (POST `/v1/search`) instead. The old→new mapping: + +| Old (removed) | New | +|---------------|-----| +| `you.agents.runs.create(request=...)` | `you.agents(request=...)` | +| `you.agents.runs.create_async(request=...)` | `you.agents_async(request=...)` | +| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/search`) | +| `you.search_post(query=...)` | `you.search(query=...)` | +| `you.search_post_async(query=...)` | `you.search_async(query=...)` | +| `you.contents.generate(urls=...)` | `you.contents(urls=...)` | +| `you.contents.generate_async(urls=...)` | `you.contents_async(urls=...)` | + +Accessing `you.agents`, `you.search`, or `you.contents` as attributes will now fail at runtime — update to the direct method calls above. + ### Added - **Answer API**: New direct method `you.answer()` / `you.answer_async()` for `POST /v1/answer`. Returns a synthesized markdown answer with inline citations (`[[1, 2]]`), a citations array (source URLs + supporting excerpts), and web results. Accepts `query` (required), `freshness`, `country`, `language`, `include_domains`, `exclude_domains`, `boost_domains`. Requires an API key. Country and language accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase automatically. @@ -15,12 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The `search_helpers` module targets the keyless-capable proxy at `api.you.com/v1/agents/search`; the generated `search.unified` / `search_post` methods target `/v1/search` on the same host. +- **Direct methods on `You`**: The sub-SDK access patterns (`you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()`) and the direct aliases (`you.create_run()`, `you.search_unified()`, `you.generate_contents()`, `you.search_post()`) from the prior deprecation have all been replaced with the final direct method names: `you.agents()`, `you.search()`, `you.contents()` (plus async variants). See the Breaking Changes table above. +- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The `search_helpers` module targets the keyless-capable proxy at `api.you.com/v1/agents/search`; the `you.search()` method targets `/v1/search` on the same host. - **422 error data model**: `UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. All three 422 response shapes deserialize without crashing. Backward compatible — existing code accessing `.error` still works. - **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. - **Removed `YDCUserAgentOverrideHook`**: The hook existed to rewrite Speakeasy's default UA (`speakeasy-sdk/python ...`) to `youdotcom-python-sdk/{version}`. Now that `__user_agent__` is already `youdotcom-python-sdk/{version}`, `BaseSDK._build_request` sets it directly — the hook was a no-op. Integrations that need a custom UA still just set `client.sdk_configuration.user_agent`. -- **Direct methods replacing sub-SDKs**: Added `you.create_run()`, `you.search_unified()`, `you.generate_contents()` (plus async variants) as direct methods on `You`, replacing the `you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()` sub-SDK paths. The sub-SDK accessors (`you.agents`, `you.search`, `you.contents`) still work but emit `DeprecationWarning` and will be removed in a future major version. +- **`__user_agent__` derived from resolved `__version__`**: The user-agent string is now built from the package's resolved version at runtime rather than a hardcoded value. ## [2.5.0] - 2026-07-20 diff --git a/MIGRATION.md b/MIGRATION.md index 15d9e66..f780bee 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,8 @@ # Migration Guide -## 2.5.0 → Unreleased +## 2.5.0 → Unreleased (major version) + +> **This is a major version release with breaking changes.** Sub-SDKs have been removed and methods are now direct on the `You` class. Update your code before upgrading. ### Answer API @@ -54,20 +56,21 @@ with You() as you: The SDK is now hand-maintained. All "Code generated by Speakeasy — DO NOT EDIT" disclaimers have been removed. The `__gen_version__` / `SPEAKEASY_GENERATOR_VERSION` exports have been removed. -### Sub-SDK Deprecation +### Sub-SDK Removal (Breaking) -The sub-SDK access patterns (`you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()`) are deprecated. Direct methods are now available on `You`: +The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) have been removed. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. Methods are now direct on the `You` class: -| Old (deprecated) | New | -|------------------|-----| -| `you.agents.runs.create(request=...)` | `you.create_run(request=...)` | -| `you.agents.runs.create_async(request=...)` | `you.create_run_async(request=...)` | -| `you.search.unified(query=...)` | `you.search_unified(query=...)` | -| `you.search.unified_async(query=...)` | `you.search_unified_async(query=...)` | -| `you.contents.generate(urls=...)` | `you.generate_contents(urls=...)` | -| `you.contents.generate_async(urls=...)` | `you.generate_contents_async(urls=...)` | +| Old (removed) | New | +|---------------|-----| +| `you.agents.runs.create(request=...)` | `you.agents(request=...)` | +| `you.agents.runs.create_async(request=...)` | `you.agents_async(request=...)` | +| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/search`) | +| `you.search_post(query=...)` | `you.search(query=...)` | +| `you.search_post_async(query=...)` | `you.search_async(query=...)` | +| `you.contents.generate(urls=...)` | `you.contents(urls=...)` | +| `you.contents.generate_async(urls=...)` | `you.contents_async(urls=...)` | -Accessing `you.agents`, `you.search`, or `you.contents` still works but emits `DeprecationWarning`. The sub-SDKs will be removed in a future major version. +`GET /v1/search` (`you.search.unified()`) has been removed. Use `POST /v1/search` (`you.search()`) instead. Accessing `you.agents`, `you.search`, or `you.contents` as sub-SDK attributes will now fail at runtime — update to the direct method calls above. ## 2.4.0 → 2.5.0 diff --git a/README.md b/README.md index a62bb92..5a7479a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -169,7 +169,7 @@ async def main(): api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = await you.search_post_async(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = await you.search_async(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -206,7 +206,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -228,33 +228,15 @@ with You( ### [You SDK](docs/sdks/you/README.md) -* [search_post](docs/sdks/you/README.md#search_post) - Returns a list of unified search results from web and news sources +* [answer](docs/sdks/answer/README.md#answer) - Returns a synthesized answer with citations from web search results +* [agents](docs/sdks/you/README.md#agents) - Run an Agent +* [search](docs/sdks/you/README.md#search) - Returns a list of unified search results from web and news sources +* [contents](docs/sdks/you/README.md#contents) - Returns the content of the web pages * [research](docs/sdks/you/README.md#research) - Returns comprehensive research-grade answers with multi-step reasoning * [get_research_task](docs/sdks/you/README.md#get_research_task) - Get the status of a background research task * [stream_research_task](docs/sdks/you/README.md#stream_research_task) - Stream updates for a background research task * [finance_research](docs/sdks/you/README.md#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning -* [answer](docs/sdks/answer/README.md#answer) - Returns a synthesized answer with citations from web search results -* [create_run](docs/sdks/you/README.md#create_run) - Run an Agent -* [search_unified](docs/sdks/you/README.md#search_unified) - Returns a list of unified search results from web and news sources -* [generate_contents](docs/sdks/you/README.md#generate_contents) - Returns the content of the web pages - -### [Agents.Runs](docs/sdks/runs/README.md) (deprecated — use `you.create_run()`) - -* [create](docs/sdks/runs/README.md#create) - Run an Agent - -### [Contents](docs/sdks/contentssdk/README.md) (deprecated — use `you.generate_contents()`) - -* [generate](docs/sdks/contentssdk/README.md#generate) - Returns the content of the web pages - -### [Search](docs/sdks/search/README.md) (deprecated — use `you.search_unified()`) - -* [unified](docs/sdks/search/README.md#unified) - Returns a list of unified search results from web and news sources - -### [Answer](docs/sdks/answer/README.md) - -* [answer](docs/sdks/answer/README.md#answer) - Returns a synthesized answer with citations from web search results - ### Keyless Search The SDK supports keyless search via the `/v1/agents/search` proxy endpoint. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). @@ -307,7 +289,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - response = you.agents.runs.create(request=ExpressAgentRunsRequest( + response = you.agents(request=ExpressAgentRunsRequest( input="Restaurants in San Francisco", stream=True, tools=[ @@ -377,7 +359,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -403,7 +385,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -443,7 +425,7 @@ with You( res = None try: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -551,7 +533,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ diff --git a/docs/sdks/contentssdk/README.md b/docs/sdks/contentssdk/README.md index b1617a1..cdea1d0 100644 --- a/docs/sdks/contentssdk/README.md +++ b/docs/sdks/contentssdk/README.md @@ -1,5 +1,12 @@ # Contents +> **DEPRECATED — removed in the current major version.** The `ContentsSDK` sub-SDK is no longer available. Use the direct method on the `You` client instead: +> +> - `you.contents(urls=...)` (was `you.contents.generate(urls=...)`) +> - `you.contents_async(urls=...)` (was `you.contents.generate_async(urls=...)`) +> +> See [docs/sdks/you/README.md](../you/README.md#contents) for the current API. The content below is kept for reference only. + ## Overview ### Available Operations diff --git a/docs/sdks/runs/README.md b/docs/sdks/runs/README.md index 11795dd..6d9ef70 100644 --- a/docs/sdks/runs/README.md +++ b/docs/sdks/runs/README.md @@ -1,5 +1,12 @@ # Agents.Runs +> **DEPRECATED — removed in the current major version.** The `Agents` sub-SDK is no longer available. Use the direct method on the `You` client instead: +> +> - `you.agents(request=...)` (was `you.agents.runs.create(request=...)`) +> - `you.agents_async(request=...)` (was `you.agents.runs.create_async(request=...)`) +> +> See [docs/sdks/you/README.md](../you/README.md#agents) for the current API. The content below is kept for reference only. + ## Overview ### Available Operations diff --git a/docs/sdks/search/README.md b/docs/sdks/search/README.md index b02eb4d..ff4d376 100644 --- a/docs/sdks/search/README.md +++ b/docs/sdks/search/README.md @@ -1,5 +1,12 @@ # Search +> **DEPRECATED — removed in the current major version.** The `Search` sub-SDK is no longer available. `GET /v1/search` (`you.search.unified()`) has been removed. Use the direct `POST /v1/search` method on the `You` client instead: +> +> - `you.search(query=...)` (was `you.search.unified(query=...)` and `you.search_post(query=...)`) +> - `you.search_async(query=...)` (was `you.search_post_async(query=...)`) +> +> See [docs/sdks/you/README.md](../you/README.md#search) for the current API. The content below is kept for reference only. + ## Overview ### Available Operations diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 67b9a80..e3aa3a6 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -18,16 +18,15 @@ Comprehensive API for You.com services: ### Available Operations * [answer](#answer) - Returns a synthesized answer with citations from web search results -* [create_run](#create_run) - Run an Agent -* [search_unified](#search_unified) - Returns a list of unified search results from web and news sources -* [generate_contents](#generate_contents) - Returns the content of the web pages -* [search_post](#search_post) - Returns a list of unified search results from web and news sources +* [agents](#agents) - Run an Agent +* [search](#search) - Returns a list of unified search results from web and news sources +* [contents](#contents) - Returns the content of the web pages * [research](#research) - Returns comprehensive research-grade answers with multi-step reasoning * [get_research_task](#get_research_task) - Get the status of a background research task * [stream_research_task](#stream_research_task) - Stream updates for a background research task * [finance_research](#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning -## search_post +## search This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. @@ -45,7 +44,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -69,7 +68,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -93,7 +92,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -117,7 +116,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -141,7 +140,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -165,7 +164,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -189,7 +188,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ diff --git a/src/youdotcom/agents.py b/src/youdotcom/agents.py deleted file mode 100644 index 9778321..0000000 --- a/src/youdotcom/agents.py +++ /dev/null @@ -1,20 +0,0 @@ - - -from .basesdk import BaseSDK -from .sdkconfiguration import SDKConfiguration -from typing import Optional -from youdotcom.runs import Runs - - -class Agents(BaseSDK): - runs: Runs - - def __init__( - self, sdk_config: SDKConfiguration, parent_ref: Optional[object] = None - ) -> None: - BaseSDK.__init__(self, sdk_config, parent_ref=parent_ref) - self.sdk_configuration = sdk_config - self._init_sdks() - - def _init_sdks(self): - self.runs = Runs(self.sdk_configuration, parent_ref=self.parent_ref) diff --git a/src/youdotcom/contents_sdk.py b/src/youdotcom/contents_sdk.py deleted file mode 100644 index a7e4c21..0000000 --- a/src/youdotcom/contents_sdk.py +++ /dev/null @@ -1,239 +0,0 @@ - - -from .basesdk import BaseSDK -from typing import Any, Iterable, List, Mapping, Optional -from youdotcom import errors, models, utils -from youdotcom._hooks import HookContext -from youdotcom.types import OptionalNullable, UNSET -from youdotcom.utils import get_security_from_env -from youdotcom.utils.unmarshal_json_response import unmarshal_json_response - - -class ContentsSDK(BaseSDK): - def generate( - self, - *, - urls: Optional[Iterable[str]] = None, - formats: Optional[Iterable[models.ContentsFormats]] = None, - crawl_timeout: Optional[int] = 10, - max_age: OptionalNullable[int] = None, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> List[models.ContentsResponse]: - r"""Returns the content of the web pages - - Returns the HTML or Markdown of a target webpage. - - :param urls: Array of URLs to fetch the contents from. - :param formats: Array of content formats to return. All included formats are returned in the response. Include \"metadata\" to get JSON-LD and OpenGraph information, if available. - :param crawl_timeout: Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. - :param max_age: Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age). - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.CONTENTS_OP_SERVERS[0] - - request = models.ContentsRequest( - urls=utils.unmarshal(urls, Optional[List[str]]), - formats=utils.unmarshal(formats, Optional[List[models.ContentsFormats]]), - crawl_timeout=crawl_timeout, - max_age=max_age, - ) - - req = self._build_request( - method="POST", - path="/v1/contents", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.ContentsRequest - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = self.do_request( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="contents", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["contents"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(List[models.ContentsResponse], http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsUnauthorizedErrorData, http_res - ) - raise errors.ContentsUnauthorizedError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsForbiddenErrorData, http_res - ) - raise errors.ContentsForbiddenError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsInternalServerErrorData, http_res - ) - raise errors.ContentsInternalServerError(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) - - async def generate_async( - self, - *, - urls: Optional[Iterable[str]] = None, - formats: Optional[Iterable[models.ContentsFormats]] = None, - crawl_timeout: Optional[int] = 10, - max_age: OptionalNullable[int] = None, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> List[models.ContentsResponse]: - r"""Returns the content of the web pages - - Returns the HTML or Markdown of a target webpage. - - :param urls: Array of URLs to fetch the contents from. - :param formats: Array of content formats to return. All included formats are returned in the response. Include \"metadata\" to get JSON-LD and OpenGraph information, if available. - :param crawl_timeout: Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. - :param max_age: Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age). - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.CONTENTS_OP_SERVERS[0] - - request = models.ContentsRequest( - urls=utils.unmarshal(urls, Optional[List[str]]), - formats=utils.unmarshal(formats, Optional[List[models.ContentsFormats]]), - crawl_timeout=crawl_timeout, - max_age=max_age, - ) - - req = self._build_request_async( - method="POST", - path="/v1/contents", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.ContentsRequest - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = await self.do_request_async( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="contents", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["contents"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(List[models.ContentsResponse], http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsUnauthorizedErrorData, http_res - ) - raise errors.ContentsUnauthorizedError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsForbiddenErrorData, http_res - ) - raise errors.ContentsForbiddenError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.ContentsInternalServerErrorData, http_res - ) - raise errors.ContentsInternalServerError(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/src/youdotcom/runs.py b/src/youdotcom/runs.py deleted file mode 100644 index 9fb8910..0000000 --- a/src/youdotcom/runs.py +++ /dev/null @@ -1,293 +0,0 @@ - - -from .basesdk import BaseSDK -from typing import Any, Mapping, Optional, Union, cast -from youdotcom import errors, models, utils -from youdotcom._hooks import HookContext -from youdotcom.types import BaseModel, OptionalNullable, UNSET -from youdotcom.utils import eventstreaming, get_security_from_env -from youdotcom.utils.unmarshal_json_response import unmarshal_json_response - - -class Runs(BaseSDK): - def create( - self, - *, - request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> Union[ - models.AgentRunsBatchResponse, - eventstreaming.EventStream[models.AgentRunsStreamingResponse], - ]: - r"""Run an Agent - - Execute queries using You.com's AI agents. This endpoint supports three agent types: - - - **Express Agent**: Fast responses with optional web search (max 1 search) - - **Advanced Agent**: Complex queries with multi-turn reasoning, planning, and tool usage - - **Custom Agent**: User-configured assistants created in the You.com UI - - The response format depends on the `stream` parameter - either a complete JSON payload or Server-Sent Events (SSE). - - - :param request: The request object to send. - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.AGENTS_RUNS_OP_SERVERS[0] - - if not isinstance(request, BaseModel): - request = utils.unmarshal(request, models.AgentsRunsRequest) - request = cast(models.AgentsRunsRequest, request) - - req = self._build_request( - method="POST", - path="/v1/agents/runs", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="text/event-stream" - if getattr(request, "stream", False) is True - else "application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.AgentsRunsRequest - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = self.do_request( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="AgentsRuns", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["agents.runs"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - stream=getattr(request, "stream", False) is True, - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - http_res_text = utils.stream_to_text(http_res) - return unmarshal_json_response( - models.AgentRunsBatchResponse, http_res, http_res_text - ) - if utils.match_response(http_res, "200", "text/event-stream"): - return eventstreaming.EventStream( - http_res, - lambda raw: unmarshal_json_response( - models.AgentRunsStreamingResponse, http_res, raw - ), - client_ref=self, - ) - if utils.match_response(http_res, "400", "application/json"): - http_res_text = utils.stream_to_text(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns400ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns400ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "401", "application/json"): - http_res_text = utils.stream_to_text(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns401ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns401ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "422", "application/json"): - http_res_text = utils.stream_to_text(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns422ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns422ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError( - "Unexpected response received", http_res, http_res_text - ) - - async def create_async( - self, - *, - request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> Union[ - models.AgentRunsBatchResponse, - eventstreaming.EventStreamAsync[models.AgentRunsStreamingResponse], - ]: - r"""Run an Agent - - Execute queries using You.com's AI agents. This endpoint supports three agent types: - - - **Express Agent**: Fast responses with optional web search (max 1 search) - - **Advanced Agent**: Complex queries with multi-turn reasoning, planning, and tool usage - - **Custom Agent**: User-configured assistants created in the You.com UI - - The response format depends on the `stream` parameter - either a complete JSON payload or Server-Sent Events (SSE). - - - :param request: The request object to send. - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.AGENTS_RUNS_OP_SERVERS[0] - - if not isinstance(request, BaseModel): - request = utils.unmarshal(request, models.AgentsRunsRequest) - request = cast(models.AgentsRunsRequest, request) - - req = self._build_request_async( - method="POST", - path="/v1/agents/runs", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="text/event-stream" - if getattr(request, "stream", False) is True - else "application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.AgentsRunsRequest - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = await self.do_request_async( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="AgentsRuns", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["agents.runs"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - stream=getattr(request, "stream", False) is True, - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - http_res_text = await utils.stream_to_text_async(http_res) - return unmarshal_json_response( - models.AgentRunsBatchResponse, http_res, http_res_text - ) - if utils.match_response(http_res, "200", "text/event-stream"): - return eventstreaming.EventStreamAsync( - http_res, - lambda raw: unmarshal_json_response( - models.AgentRunsStreamingResponse, http_res, raw - ), - client_ref=self, - ) - if utils.match_response(http_res, "400", "application/json"): - http_res_text = await utils.stream_to_text_async(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns400ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns400ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "401", "application/json"): - http_res_text = await utils.stream_to_text_async(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns401ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns401ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "422", "application/json"): - http_res_text = await utils.stream_to_text_async(http_res) - response_data = unmarshal_json_response( - errors.AgentRuns422ResponseErrorData, http_res, http_res_text - ) - raise errors.AgentRuns422ResponseError( - response_data, http_res, http_res_text - ) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError( - "Unexpected response received", http_res, http_res_text - ) diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index c949b3d..25a9047 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -7,8 +7,6 @@ from .utils.retries import RetryConfig import httpx import importlib -import sys -import warnings from typing import ( Any, Callable, @@ -24,15 +22,10 @@ import weakref from youdotcom import errors, models, utils from youdotcom._hooks import HookContext, SDKHooks -from youdotcom.types import OptionalNullable, UNSET +from youdotcom.types import BaseModel, OptionalNullable, UNSET from youdotcom.utils import eventstreaming, get_security_from_env from youdotcom.utils.unmarshal_json_response import unmarshal_json_response -if TYPE_CHECKING: - from youdotcom.agents import Agents - from youdotcom.contents_sdk import ContentsSDK - from youdotcom.search import Search - class You(BaseSDK): r"""You.com API: Unified API for Express, Advanced, and Custom Agents from You.com @@ -49,15 +42,6 @@ class You(BaseSDK): - **Contents API**: Retrieve and process web page content """ - agents: "Agents" - search: "Search" - contents: "ContentsSDK" - _sub_sdk_map = { - "agents": ("youdotcom.agents", "Agents"), - "search": ("youdotcom.search", "Search"), - "contents": ("youdotcom.contents_sdk", "ContentsSDK"), - } - def __init__( self, api_key_auth: Optional[ @@ -151,56 +135,6 @@ def __init__( self.sdk_configuration.async_client_supplied, ) - def dynamic_import(self, modname, retries=3): - for attempt in range(retries): - try: - return importlib.import_module(modname) - except KeyError: - # Clear any half-initialized module and retry - sys.modules.pop(modname, None) - if attempt == retries - 1: - break - raise KeyError(f"Failed to import module '{modname}' after {retries} attempts") - - def __getattr__(self, name: str): - if name in self._sub_sdk_map: - _DEPRECATED_SUB_SDKS = { - "agents": "you.create_run()", - "search": "you.search_unified()", - "contents": "you.generate_contents()", - } - if name in _DEPRECATED_SUB_SDKS: - warnings.warn( - f"you.{name} is deprecated and will be removed in a future major version. " - f"Use {_DEPRECATED_SUB_SDKS[name]} instead.", - DeprecationWarning, - stacklevel=2, - ) - module_path, class_name = self._sub_sdk_map[name] - try: - module = self.dynamic_import(module_path) - klass = getattr(module, class_name) - instance = klass(self.sdk_configuration, parent_ref=self) - setattr(self, name, instance) - return instance - except ImportError as e: - raise AttributeError( - f"Failed to import module {module_path} for attribute {name}: {e}" - ) from e - except AttributeError as e: - raise AttributeError( - f"Failed to find class {class_name} in module {module_path} for attribute {name}: {e}" - ) from e - - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) - - def __dir__(self): - default_attrs = list(super().__dir__()) - lazy_attrs = list(self._sub_sdk_map.keys()) - return sorted(list(set(default_attrs + lazy_attrs))) - def __enter__(self): return self @@ -519,13 +453,7 @@ async def answer_async( raise errors.YouDefaultError("Unexpected response received", http_res) - def _get_sub_sdk(self, name: str): - """Access a sub-SDK without triggering the deprecation warning.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - return getattr(self, name) - - def create_run( + def agents( self, *, request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], @@ -537,25 +465,136 @@ def create_run( models.AgentRunsBatchResponse, eventstreaming.EventStream[models.AgentRunsStreamingResponse], ]: - r"""Run an Agent. + r"""Run an Agent + + Execute queries using You.com's AI agents. This endpoint supports three agent types: + + - **Express Agent**: Fast responses with optional web search (max 1 search) + - **Advanced Agent**: Complex queries with multi-turn reasoning, planning, and tool usage + - **Custom Agent**: User-configured assistants created in the You.com UI + + The response format depends on the `stream` parameter - either a complete JSON payload or Server-Sent Events (SSE). - Direct method replacing ``you.agents.runs.create()``. :param request: The request object to send. :param retries: Override the default retry configuration for this method :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout in milliseconds + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds :param http_headers: Additional headers to set or replace on requests. """ - return self._get_sub_sdk("agents").runs.create( + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + if not isinstance(request, BaseModel): + request = utils.unmarshal(request, models.AgentsRunsRequest) + request = cast(models.AgentsRunsRequest, request) + + req = self._build_request( + method="POST", + path="/v1/agents/runs", + base_url=base_url, + url_variables=url_variables, request=request, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="text/event-stream" + if getattr(request, "stream", False) is True + else "application/json", http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AgentsRunsRequest + ), + allow_empty_value=None, + timeout_ms=timeout_ms, + ) + + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="AgentsRuns", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["agents.runs"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + stream=getattr(request, "stream", False) is True, + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + http_res_text = utils.stream_to_text(http_res) + return unmarshal_json_response( + models.AgentRunsBatchResponse, http_res, http_res_text + ) + if utils.match_response(http_res, "200", "text/event-stream"): + return eventstreaming.EventStream( + http_res, + lambda raw: unmarshal_json_response( + models.AgentRunsStreamingResponse, http_res, raw + ), + client_ref=self, + ) + if utils.match_response(http_res, "400", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns400ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns400ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "401", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns401ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns401ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "422", "application/json"): + http_res_text = utils.stream_to_text(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns422ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns422ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError( + "Unexpected response received", http_res, http_res_text ) - async def create_run_async( + async def agents_async( self, *, request: Union[models.AgentsRunsRequest, models.AgentsRunsRequestTypedDict], @@ -567,137 +606,136 @@ async def create_run_async( models.AgentRunsBatchResponse, eventstreaming.EventStreamAsync[models.AgentRunsStreamingResponse], ]: - r"""Run an Agent (async). + r"""Run an Agent + + Execute queries using You.com's AI agents. This endpoint supports three agent types: + + - **Express Agent**: Fast responses with optional web search (max 1 search) + - **Advanced Agent**: Complex queries with multi-turn reasoning, planning, and tool usage + - **Custom Agent**: User-configured assistants created in the You.com UI + + The response format depends on the `stream` parameter - either a complete JSON payload or Server-Sent Events (SSE). - Direct method replacing ``you.agents.runs.create_async()``. :param request: The request object to send. :param retries: Override the default retry configuration for this method :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout in milliseconds + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds :param http_headers: Additional headers to set or replace on requests. """ - return await self._get_sub_sdk("agents").runs.create_async( + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + if not isinstance(request, BaseModel): + request = utils.unmarshal(request, models.AgentsRunsRequest) + request = cast(models.AgentsRunsRequest, request) + + req = self._build_request_async( + method="POST", + path="/v1/agents/runs", + base_url=base_url, + url_variables=url_variables, request=request, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="text/event-stream" + if getattr(request, "stream", False) is True + else "application/json", http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.AgentsRunsRequest + ), + allow_empty_value=None, + timeout_ms=timeout_ms, ) - def search_unified( - self, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, - include_domains: Optional[str] = None, - exclude_domains: Optional[str] = None, - boost_domains: Optional[str] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources. - - Direct method replacing ``you.search.unified()``. - - :param query: The search query. - :param count: Max results per section. - :param freshness: ``day``, ``week``, ``month``, ``year``, or ``YYYY-MM-DDtoYYYY-MM-DD``. - :param offset: Pagination offset. - :param country: Country code for geographical focus. - :param language: BCP 47 language code (default ``EN``). - :param safesearch: ``strict``, ``moderate``, or ``off``. - :param livecrawl: ``web``, ``news``, or ``all``. - :param livecrawl_formats: ``["html"]``, ``["markdown"]``, or both. - :param include_domains: Comma-separated domains to restrict results to. - :param exclude_domains: Comma-separated domains to exclude. - :param boost_domains: Comma-separated domains to boost in ranking. - :param crawl_timeout: Max seconds to wait for livecrawl (1-60, default 10). - :param retries: Override the default retry configuration. - :param server_url: Override the default server URL. - :param timeout_ms: Override the request timeout in milliseconds. - :param http_headers: Additional headers to set or replace. - """ - return self._get_sub_sdk("search").unified( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country, - language=language, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=livecrawl_formats, - include_domains=include_domains, - exclude_domains=exclude_domains, - boost_domains=boost_domains, - crawl_timeout=crawl_timeout, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, - http_headers=http_headers, + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="AgentsRuns", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["agents.runs"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + stream=getattr(request, "stream", False) is True, + retry_config=retry_config, ) - async def search_unified_async( - self, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, - include_domains: Optional[str] = None, - exclude_domains: Optional[str] = None, - boost_domains: Optional[str] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources (async). + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + return unmarshal_json_response( + models.AgentRunsBatchResponse, http_res, http_res_text + ) + if utils.match_response(http_res, "200", "text/event-stream"): + return eventstreaming.EventStreamAsync( + http_res, + lambda raw: unmarshal_json_response( + models.AgentRunsStreamingResponse, http_res, raw + ), + client_ref=self, + ) + if utils.match_response(http_res, "400", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns400ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns400ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "401", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns401ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns401ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "422", "application/json"): + http_res_text = await utils.stream_to_text_async(http_res) + response_data = unmarshal_json_response( + errors.AgentRuns422ResponseErrorData, http_res, http_res_text + ) + raise errors.AgentRuns422ResponseError( + response_data, http_res, http_res_text + ) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - Direct method replacing ``you.search.unified_async()``. - """ - return await self._get_sub_sdk("search").unified_async( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country, - language=language, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=livecrawl_formats, - include_domains=include_domains, - exclude_domains=exclude_domains, - boost_domains=boost_domains, - crawl_timeout=crawl_timeout, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, - http_headers=http_headers, + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError( + "Unexpected response received", http_res, http_res_text ) - def generate_contents( + def contents( self, *, urls: Optional[Iterable[str]] = None, @@ -709,31 +747,109 @@ def generate_contents( timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, ) -> List[models.ContentsResponse]: - r"""Returns the content of the web pages. + r"""Returns the content of the web pages - Direct method replacing ``you.contents.generate()``. + Returns the HTML or Markdown of a target webpage. :param urls: Array of URLs to fetch the contents from. - :param formats: Array of content formats to return (``html``, ``markdown``, ``metadata``). - :param crawl_timeout: Maximum time in seconds to wait for page content (1-60, default 10). - :param max_age: Maximum allowed age of cached content in seconds. - :param retries: Override the default retry configuration. - :param server_url: Override the default server URL. - :param timeout_ms: Override the request timeout in milliseconds. - :param http_headers: Additional headers to set or replace. + :param formats: Array of content formats to return. All included formats are returned in the response. Include \"metadata\" to get JSON-LD and OpenGraph information, if available. + :param crawl_timeout: Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. + :param max_age: Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age). + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. """ - return self._get_sub_sdk("contents").generate( - urls=urls, - formats=formats, + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + request = models.ContentsRequest( + urls=utils.unmarshal(urls, Optional[List[str]]), + formats=utils.unmarshal(formats, Optional[List[models.ContentsFormats]]), crawl_timeout=crawl_timeout, max_age=max_age, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, + ) + + req = self._build_request( + method="POST", + path="/v1/contents", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.ContentsRequest + ), + allow_empty_value=None, + timeout_ms=timeout_ms, ) - async def generate_contents_async( + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = self.do_request( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="contents", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["contents"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(List[models.ContentsResponse], http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsUnauthorizedErrorData, http_res + ) + raise errors.ContentsUnauthorizedError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsForbiddenErrorData, http_res + ) + raise errors.ContentsForbiddenError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsInternalServerErrorData, http_res + ) + raise errors.ContentsInternalServerError(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = utils.stream_to_text(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + async def contents_async( self, *, urls: Optional[Iterable[str]] = None, @@ -745,22 +861,109 @@ async def generate_contents_async( timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, ) -> List[models.ContentsResponse]: - r"""Returns the content of the web pages (async). + r"""Returns the content of the web pages - Direct method replacing ``you.contents.generate_async()``. + Returns the HTML or Markdown of a target webpage. + + :param urls: Array of URLs to fetch the contents from. + :param formats: Array of content formats to return. All included formats are returned in the response. Include \"metadata\" to get JSON-LD and OpenGraph information, if available. + :param crawl_timeout: Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. + :param max_age: Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age). + :param retries: Override the default retry configuration for this method + :param server_url: Override the default server URL for this method + :param timeout_ms: Override the default request timeout configuration for this method in milliseconds + :param http_headers: Additional headers to set or replace on requests. """ - return await self._get_sub_sdk("contents").generate_async( - urls=urls, - formats=formats, + base_url = None + url_variables = None + if timeout_ms is None: + timeout_ms = self.sdk_configuration.timeout_ms + + if server_url is not None: + base_url = server_url + else: + base_url = self._get_url(None, None) + + request = models.ContentsRequest( + urls=utils.unmarshal(urls, Optional[List[str]]), + formats=utils.unmarshal(formats, Optional[List[models.ContentsFormats]]), crawl_timeout=crawl_timeout, max_age=max_age, - retries=retries, - server_url=server_url, - timeout_ms=timeout_ms, + ) + + req = self._build_request_async( + method="POST", + path="/v1/contents", + base_url=base_url, + url_variables=url_variables, + request=request, + request_body_required=True, + request_has_path_params=False, + request_has_query_params=True, + user_agent_header="user-agent", + accept_header_value="application/json", http_headers=http_headers, + security=self.sdk_configuration.security, + get_serialized_body=lambda: utils.serialize_request_body( + request, False, False, "json", models.ContentsRequest + ), + allow_empty_value=None, + timeout_ms=timeout_ms, ) - def search_post( + if retries == UNSET: + if self.sdk_configuration.retry_config is not UNSET: + retries = self.sdk_configuration.retry_config + + retry_config = None + if isinstance(retries, utils.RetryConfig): + retry_config = (retries, ["429", "500", "502", "503", "504"]) + + http_res = await self.do_request_async( + hook_ctx=HookContext( + config=self.sdk_configuration, + base_url=base_url or "", + operation_id="contents", + oauth2_scopes=None, + security_source=get_security_from_env( + self.sdk_configuration.security, models.Security + ), + tags=["contents"], + extensions=None, + ), + request=req, + is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), + retry_config=retry_config, + ) + + response_data: Any = None + if utils.match_response(http_res, "200", "application/json"): + return unmarshal_json_response(List[models.ContentsResponse], http_res) + if utils.match_response(http_res, "401", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsUnauthorizedErrorData, http_res + ) + raise errors.ContentsUnauthorizedError(response_data, http_res) + if utils.match_response(http_res, "403", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsForbiddenErrorData, http_res + ) + raise errors.ContentsForbiddenError(response_data, http_res) + if utils.match_response(http_res, "500", "application/json"): + response_data = unmarshal_json_response( + errors.ContentsInternalServerErrorData, http_res + ) + raise errors.ContentsInternalServerError(response_data, http_res) + if utils.match_response(http_res, "4XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + if utils.match_response(http_res, "5XX", "*"): + http_res_text = await utils.stream_to_text_async(http_res) + raise errors.YouDefaultError("API error occurred", http_res, http_res_text) + + raise errors.YouDefaultError("Unexpected response received", http_res) + + def search( self, *, query: str, @@ -918,7 +1121,7 @@ def search_post( raise errors.YouDefaultError("Unexpected response received", http_res) - async def search_post_async( + async def search_async( self, *, query: str, diff --git a/src/youdotcom/search.py b/src/youdotcom/search.py deleted file mode 100644 index 3c9accb..0000000 --- a/src/youdotcom/search.py +++ /dev/null @@ -1,325 +0,0 @@ - - -from .basesdk import BaseSDK -from typing import Any, Iterable, List, Mapping, Optional, Union -from youdotcom import errors, models, utils -from youdotcom._hooks import HookContext -from youdotcom.types import OptionalNullable, UNSET -from youdotcom.utils import get_security_from_env -from youdotcom.utils.unmarshal_json_response import unmarshal_json_response - - -class Search(BaseSDK): - def unified( - self, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, - include_domains: Optional[str] = None, - exclude_domains: Optional[str] = None, - boost_domains: Optional[str] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources - - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `GET` is a good choice for simple queries where HTTP cacheability matters—GET responses can be cached at CDN and proxy layers, whereas POST responses are not cached by default per the HTTP spec. For requests with complex parameters such as `include_domains` or `exclude_domains`, use POST instead - domain lists are passed as comma-separated strings in GET and are limited by URL length. - - :param query: - :param count: - :param freshness: Specifies the freshness of the results to return. Provide either one of `day`, `week`, `month`, `year`, or a date range string in the format `YYYY-MM-DDtoYYYY-MM-DD`. - - When your search query includes a temporal keyword and you also set a freshness parameter, the search will use the broader (i.e., less restrictive) of the two timeframes. For example, if you use `query=news+this+week&freshness=month`, the results will use a freshness of month. - :param offset: - :param country: The country code that determines the geographical focus of the web results. - :param language: The language of the web results that will be returned (BCP 47 format). - :param safesearch: Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. - :param livecrawl: Indicates which section(s) of search results to livecrawl and return full page content. - :param livecrawl_formats: - :param include_domains: A list of domains to restrict search results to. Only results from these domains will be returned. For large domain lists (up to 500), use POST with a JSON array instead. This is a strict allowlist — cannot be combined with `exclude_domains` (returns `422`). - - **Important:** Use a single comma-separated value (e.g. `include_domains=nytimes.com,bbc.com`). Repeated parameters (`include_domains=a.com&include_domains=b.com`) are not supported. - :param exclude_domains: A list of domains to exclude from search results. Results from these domains will be filtered out. For large domain lists (up to 500), use POST with a JSON array instead. Cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `exclude_domains=spam-site.com,other-site.com`). Repeated parameters are not supported. - :param boost_domains: A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `boost_domains=nytimes.com,wired.com`). Repeated parameters are not supported. - :param crawl_timeout: - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.SEARCH_OP_SERVERS[0] - - request = models.SearchRequest( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country, - language=language, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=utils.unmarshal( - livecrawl_formats, Optional[List[models.LiveCrawlFormats]] - ), - include_domains=include_domains, - exclude_domains=exclude_domains, - boost_domains=boost_domains, - crawl_timeout=crawl_timeout, - ) - - req = self._build_request( - method="GET", - path="/v1/search", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=False, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = self.do_request( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="search", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["search"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) - - async def unified_async( - self, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, - offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, - include_domains: Optional[str] = None, - exclude_domains: Optional[str] = None, - boost_domains: Optional[str] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, - ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources - - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `GET` is a good choice for simple queries where HTTP cacheability matters—GET responses can be cached at CDN and proxy layers, whereas POST responses are not cached by default per the HTTP spec. For requests with complex parameters such as `include_domains` or `exclude_domains`, use POST instead - domain lists are passed as comma-separated strings in GET and are limited by URL length. - - :param query: - :param count: - :param freshness: Specifies the freshness of the results to return. Provide either one of `day`, `week`, `month`, `year`, or a date range string in the format `YYYY-MM-DDtoYYYY-MM-DD`. - - When your search query includes a temporal keyword and you also set a freshness parameter, the search will use the broader (i.e., less restrictive) of the two timeframes. For example, if you use `query=news+this+week&freshness=month`, the results will use a freshness of month. - :param offset: - :param country: The country code that determines the geographical focus of the web results. - :param language: The language of the web results that will be returned (BCP 47 format). - :param safesearch: Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. - :param livecrawl: Indicates which section(s) of search results to livecrawl and return full page content. - :param livecrawl_formats: - :param include_domains: A list of domains to restrict search results to. Only results from these domains will be returned. For large domain lists (up to 500), use POST with a JSON array instead. This is a strict allowlist — cannot be combined with `exclude_domains` (returns `422`). - - **Important:** Use a single comma-separated value (e.g. `include_domains=nytimes.com,bbc.com`). Repeated parameters (`include_domains=a.com&include_domains=b.com`) are not supported. - :param exclude_domains: A list of domains to exclude from search results. Results from these domains will be filtered out. For large domain lists (up to 500), use POST with a JSON array instead. Cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `exclude_domains=spam-site.com,other-site.com`). Repeated parameters are not supported. - :param boost_domains: A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - - **Important:** You must use a single comma-separated value (e.g. `boost_domains=nytimes.com,wired.com`). Repeated parameters are not supported. - :param crawl_timeout: - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. - """ - base_url = None - url_variables = None - if timeout_ms is None: - timeout_ms = self.sdk_configuration.timeout_ms - - if server_url is not None: - base_url = server_url - else: - base_url = models.SEARCH_OP_SERVERS[0] - - request = models.SearchRequest( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country, - language=language, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=utils.unmarshal( - livecrawl_formats, Optional[List[models.LiveCrawlFormats]] - ), - include_domains=include_domains, - exclude_domains=exclude_domains, - boost_domains=boost_domains, - crawl_timeout=crawl_timeout, - ) - - req = self._build_request_async( - method="GET", - path="/v1/search", - base_url=base_url, - url_variables=url_variables, - request=request, - request_body_required=False, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=self.sdk_configuration.security, - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if self.sdk_configuration.retry_config is not UNSET: - retries = self.sdk_configuration.retry_config - - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = await self.do_request_async( - hook_ctx=HookContext( - config=self.sdk_configuration, - base_url=base_url or "", - operation_id="search", - oauth2_scopes=None, - security_source=get_security_from_env( - self.sdk_configuration.security, models.Security - ), - tags=["search"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - if utils.match_response(http_res, "5XX", "*"): - http_res_text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, http_res_text) - - raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/tests/PERFORMANCE_TESTING.md b/tests/PERFORMANCE_TESTING.md index e91d623..c102d3c 100644 --- a/tests/PERFORMANCE_TESTING.md +++ b/tests/PERFORMANCE_TESTING.md @@ -338,7 +338,7 @@ def test_search_with_new_filter(self, server_url, api_key, iterations, show_deta with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="test", new_filter="value") + you.search(query="test", new_filter="value") metrics = measure_sdk_call(call, client, iterations, "Search: new filter") ALL_METRICS.append(metrics) diff --git a/tests/test_contents.py b/tests/test_contents.py index 87ce0fa..3b108ee 100644 --- a/tests/test_contents.py +++ b/tests/test_contents.py @@ -26,7 +26,7 @@ def test_html_format(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.python.org", "https://www.example.com"], formats=[ContentsFormats.HTML], server_url=server_url, @@ -41,7 +41,7 @@ def test_markdown_format(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.MARKDOWN], server_url=server_url, @@ -55,7 +55,7 @@ def test_metadata_format(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.METADATA], server_url=server_url, @@ -71,7 +71,7 @@ def test_multiple_formats(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.HTML, ContentsFormats.MARKDOWN, ContentsFormats.METADATA], server_url=server_url, @@ -85,7 +85,7 @@ def test_multiple_urls(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=[ "https://www.you.com", "https://www.github.com", @@ -103,7 +103,7 @@ def test_single_url(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.HTML], server_url=server_url, @@ -117,7 +117,7 @@ def test_without_formats(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], server_url=server_url, ) @@ -130,7 +130,7 @@ def test_crawl_timeout(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.HTML], crawl_timeout=30, # Set timeout to 30 seconds @@ -145,7 +145,7 @@ def test_max_age(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.MARKDOWN], max_age=86400, # 1 day in seconds @@ -162,7 +162,7 @@ def test_unauthorized(self, server_url): with You(server_url=server_url, client=client, api_key_auth="invalid") as you: with pytest.raises(ContentsUnauthorizedError): - you.contents.generate( + you.contents( urls=["https://www.example.com"], server_url=server_url, ) @@ -172,7 +172,7 @@ def test_forbidden(self, server_url, api_key): with You(server_url=server_url, client=client, api_key_auth=api_key) as you: with pytest.raises(ContentsForbiddenError): - you.contents.generate( + you.contents( urls=["https://www.example.com"], server_url=server_url, ) @@ -181,7 +181,7 @@ def test_empty_urls(self, server_url, api_key): client = create_test_http_client("post_/v1/contents") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.contents.generate( + res = you.contents( urls=[], formats=[ContentsFormats.HTML], server_url=server_url, diff --git a/tests/test_direct_methods.py b/tests/test_direct_methods.py index e646f59..edb2356 100644 --- a/tests/test_direct_methods.py +++ b/tests/test_direct_methods.py @@ -1,12 +1,11 @@ -"""Tests for direct methods on You that replace sub-SDK access patterns. +"""Tests for the direct methods on You. -Verifies that you.create_run(), you.search_unified(), you.generate_contents() -work identically to the sub-SDK paths, and that sub-SDK access emits -DeprecationWarning. +Verifies that you.agents(), you.contents(), and you.search() hit the +correct endpoints, pass params correctly, and work in both sync and +async modes. Uses httpx.MockTransport (no live server required). """ import json -import warnings import httpx import pytest @@ -15,6 +14,7 @@ from youdotcom.models import ( AgentRunsBatchResponse, ContentsResponse, + ExpressAgentRunsRequest, SearchResponse, ) @@ -34,7 +34,7 @@ ) -def _make_client(handler, *, api_key="test-key"): +def _sync_you(handler, *, api_key: str | None = "test-key"): kwargs: dict = { "server_url": "http://mock.local", "client": httpx.Client(transport=httpx.MockTransport(handler)), @@ -44,155 +44,176 @@ def _make_client(handler, *, api_key="test-key"): return You(**kwargs) -class TestDirectMethodDelegation: - def test_search_unified_delegates_to_search_unified(self): +def _async_you(handler, *, api_key: str | None = "test-key"): + kwargs: dict = { + "server_url": "http://mock.local", + "async_client": httpx.AsyncClient(transport=httpx.MockTransport(handler)), + } + if api_key is not None: + kwargs["api_key_auth"] = api_key + return You(**kwargs) + + +# --------------------------------------------------------------------------- +# you.search() — POST /v1/search +# --------------------------------------------------------------------------- + + +class TestSearchDirect: + def test_returns_search_response(self): + res = _sync_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + )).search(query="python") + assert isinstance(res, SearchResponse) + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) == 1 + + def test_posts_to_search_endpoint(self): captured: dict = {} def handler(request): captured["url"] = str(request.url) + captured["method"] = request.method return httpx.Response( 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY ) - res = _make_client(handler).search_unified(query="python") - assert isinstance(res, SearchResponse) + _sync_you(handler).search(query="test") + assert captured["method"] == "POST" assert "/v1/search" in captured["url"] - def test_search_unified_passes_all_params(self): + def test_passes_params_in_body(self): captured: dict = {} def handler(request): - captured["params"] = dict(request.url.params) + captured["body"] = json.loads(request.content) return httpx.Response( 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY ) - _make_client(handler).search_unified( + _sync_you(handler).search( query="ai news", count=5, freshness="week", country="US", - include_domains="nytimes.com", + include_domains=["nature.com"], ) - assert captured["params"]["query"] == "ai news" - assert captured["params"]["count"] == "5" - assert captured["params"]["freshness"] == "week" + assert captured["body"]["query"] == "ai news" + assert captured["body"]["count"] == 5 + assert captured["body"]["freshness"] == "week" + assert captured["body"]["include_domains"] == ["nature.com"] + + @pytest.mark.asyncio + async def test_async_returns_search_response(self): + res = await _async_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY + )).search_async(query="python") + assert isinstance(res, SearchResponse) + + +# --------------------------------------------------------------------------- +# you.contents() — POST /v1/contents +# --------------------------------------------------------------------------- + + +class TestContentsDirect: + def test_returns_contents_response_list(self): + res = _sync_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY + )).contents(urls=["https://example.com"]) + assert isinstance(res, list) + assert isinstance(res[0], ContentsResponse) + assert res[0].url == "https://example.com" - def test_generate_contents_delegates_to_contents_generate(self): + def test_posts_to_contents_endpoint(self): captured: dict = {} def handler(request): captured["url"] = str(request.url) + captured["method"] = request.method return httpx.Response( 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY ) - res = _make_client(handler).generate_contents(urls=["https://example.com"]) - assert isinstance(res, list) - assert isinstance(res[0], ContentsResponse) + _sync_you(handler).contents(urls=["https://example.com"]) + assert captured["method"] == "POST" assert "/v1/contents" in captured["url"] - def test_create_run_delegates_to_agents_runs_create(self): + def test_passes_urls_in_body(self): captured: dict = {} def handler(request): - captured["url"] = str(request.url) captured["body"] = json.loads(request.content) return httpx.Response( - 200, headers={"content-type": "application/json"}, content=_RUNS_BODY + 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY ) - res = _make_client(handler).create_run( - request={"agent": "express", "input": "Hello"} - ) - assert isinstance(res, AgentRunsBatchResponse) - assert "/v1/agents/runs" in captured["url"] - assert captured["body"]["agent"] == "express" + _sync_you(handler).contents(urls=["https://example.com", "https://python.org"]) + assert captured["body"]["urls"] == ["https://example.com", "https://python.org"] @pytest.mark.asyncio - async def test_search_unified_async_delegates(self): - def handler(request): - return httpx.Response( - 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY - ) + async def test_async_returns_contents_response_list(self): + res = await _async_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY + )).contents_async(urls=["https://example.com"]) + assert isinstance(res, list) + assert isinstance(res[0], ContentsResponse) - you = You( - api_key_auth="test-key", - server_url="http://mock.local", - async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + +# --------------------------------------------------------------------------- +# you.agents() — POST /v1/agents/runs +# --------------------------------------------------------------------------- + + +class TestAgentsDirect: + def test_returns_agent_runs_batch_response(self): + res = _sync_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_RUNS_BODY + )).agents( + request=ExpressAgentRunsRequest(input="Hello", stream=False), ) - res = await you.search_unified_async(query="python") - assert isinstance(res, SearchResponse) + assert isinstance(res, AgentRunsBatchResponse) + assert res.output is not None + assert len(res.output) == 1 + + def test_posts_to_agents_runs_endpoint(self): + captured: dict = {} - @pytest.mark.asyncio - async def test_generate_contents_async_delegates(self): def handler(request): + captured["url"] = str(request.url) + captured["method"] = request.method return httpx.Response( - 200, headers={"content-type": "application/json"}, content=_CONTENTS_BODY + 200, headers={"content-type": "application/json"}, content=_RUNS_BODY ) - you = You( - api_key_auth="test-key", - server_url="http://mock.local", - async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + _sync_you(handler).agents( + request=ExpressAgentRunsRequest(input="Hello", stream=False), ) - res = await you.generate_contents_async(urls=["https://example.com"]) - assert isinstance(res, list) - assert isinstance(res[0], ContentsResponse) + assert captured["method"] == "POST" + assert "/v1/agents/runs" in captured["url"] + + def test_passes_request_in_body(self): + captured: dict = {} - @pytest.mark.asyncio - async def test_create_run_async_delegates(self): def handler(request): + captured["body"] = json.loads(request.content) return httpx.Response( 200, headers={"content-type": "application/json"}, content=_RUNS_BODY ) - you = You( - api_key_auth="test-key", - server_url="http://mock.local", - async_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + _sync_you(handler).agents( + request=ExpressAgentRunsRequest(input="Teach me Python", stream=False), ) - res = await you.create_run_async( - request={"agent": "express", "input": "Hello"} + assert captured["body"]["agent"] == "express" + assert captured["body"]["input"] == "Teach me Python" + assert captured["body"]["stream"] is False + + @pytest.mark.asyncio + async def test_async_returns_agent_runs_batch_response(self): + res = await _async_you(lambda req: httpx.Response( + 200, headers={"content-type": "application/json"}, content=_RUNS_BODY + )).agents_async( + request=ExpressAgentRunsRequest(input="Hello", stream=False), ) assert isinstance(res, AgentRunsBatchResponse) - - -class TestDeprecationWarnings: - def test_search_access_emits_deprecation_warning(self): - you = _make_client(lambda req: httpx.Response(200)) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _ = you.search - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "you.search_unified()" in str(w[0].message) - - def test_agents_access_emits_deprecation_warning(self): - you = _make_client(lambda req: httpx.Response(200)) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _ = you.agents - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "you.create_run()" in str(w[0].message) - - def test_contents_access_emits_deprecation_warning(self): - you = _make_client(lambda req: httpx.Response(200)) - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") - _ = you.contents - assert len(w) == 1 - assert issubclass(w[0].category, DeprecationWarning) - assert "you.generate_contents()" in str(w[0].message) - - def test_sub_sdk_still_works_after_warning(self): - """Sub-SDK access still returns a working instance despite the warning.""" - you = _make_client(lambda req: httpx.Response( - 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY - )) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - search_sdk = you.search - res = search_sdk.unified(query="test") - assert isinstance(res, SearchResponse) diff --git a/tests/test_live.py b/tests/test_live.py index 6dae64f..10e6eae 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -95,7 +95,7 @@ class TestLiveSearch: def test_basic_search(self, you_client): """Test basic search functionality against live API.""" with you_client as you: - res = you.search.unified(query="Python programming language") + res = you.search(query="Python programming language") assert res.results is not None assert res.metadata is not None @@ -106,7 +106,7 @@ def test_basic_search(self, you_client): def test_search_with_filters(self, you_client): """Test search with filters against live API.""" with you_client as you: - res = you.search.unified( + res = you.search( query="artificial intelligence", count=5, freshness=Freshness.WEEK, @@ -123,7 +123,7 @@ def test_search_with_filters(self, you_client): def test_search_with_livecrawl_web(self, you_client): """Test search with livecrawl for web results.""" with you_client as you: - res = you.search.unified( + res = you.search( query="machine learning tutorials", count=3, livecrawl=LiveCrawl.WEB, @@ -143,7 +143,7 @@ def test_search_with_livecrawl_web(self, you_client): def test_search_with_livecrawl_news(self, you_client): """Test search with livecrawl for news results (new in 2.2.0).""" with you_client as you: - res = you.search.unified( + res = you.search( query="technology news today", count=3, livecrawl=LiveCrawl.NEWS, @@ -163,7 +163,7 @@ def test_search_with_livecrawl_news(self, you_client): def test_search_with_livecrawl_all(self, you_client): """Test search with livecrawl=ALL for both web and news.""" with you_client as you: - res = you.search.unified( + res = you.search( query="breaking tech news", count=3, livecrawl=LiveCrawl.ALL, @@ -180,7 +180,7 @@ class TestLiveContents: def test_html_format(self, you_client): """Test fetching content in HTML format.""" with you_client as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.HTML], ) @@ -195,7 +195,7 @@ def test_html_format(self, you_client): def test_markdown_format(self, you_client): """Test fetching content in Markdown format.""" with you_client as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.MARKDOWN], ) @@ -206,7 +206,7 @@ def test_markdown_format(self, you_client): def test_metadata_format(self, you_client): """Test fetching metadata from a page.""" with you_client as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.METADATA], ) @@ -219,7 +219,7 @@ def test_metadata_format(self, you_client): def test_multiple_formats(self, you_client): """Test fetching multiple formats at once.""" with you_client as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.HTML, ContentsFormats.MARKDOWN], ) @@ -235,7 +235,7 @@ class TestLiveAgents: def test_express_agent(self, you_client): """Test Express agent with basic query.""" with you_client as you: - res = you.agents.runs.create( + res = you.agents( request=ExpressAgentRunsRequest( input="What is the capital of France?", stream=False, @@ -250,7 +250,7 @@ def test_express_agent(self, you_client): def test_advanced_agent_with_research(self, you_client): """Test Advanced agent with ResearchTool.""" with you_client as you: - res = you.agents.runs.create( + res = you.agents( request=AdvancedAgentRunsRequest( input="What are the latest developments in AI?", stream=False, @@ -445,7 +445,7 @@ class TestLiveContentsMaxAge: def test_contents_with_max_age(self, you_client): """max_age is accepted as an optional parameter.""" with you_client as you: - res = you.contents.generate( + res = you.contents( urls=["https://www.example.com"], formats=[ContentsFormats.MARKDOWN], max_age=86400, # 1 day @@ -466,7 +466,7 @@ class TestLiveSearchBoostDomains: def test_search_post_boost_domains_list(self, you_client): """search_post accepts a Python list of boost domains.""" with you_client as you: - res = you.search_post( + res = you.search( query="Python type hints vs TypeScript inference", count=5, boost_domains=["python.org", "realpython.com"], diff --git a/tests/test_performance.py b/tests/test_performance.py index 5e958eb..88ce806 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -172,7 +172,7 @@ def test_search_basic(self, server_url, api_key, iterations, show_detailed): with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="latest AI developments", server_url=server_url) + you.search(query="latest AI developments", server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: basic query") ALL_METRICS.append(metrics) @@ -185,7 +185,7 @@ def test_search_with_count(self, server_url, api_key, iterations, show_detailed) with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="python programming", count=10, server_url=server_url) + you.search(query="python programming", count=10, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: with count=10") ALL_METRICS.append(metrics) @@ -198,7 +198,7 @@ def test_search_with_freshness_day(self, server_url, api_key, iterations, show_d with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="breaking news", freshness=Freshness.DAY, server_url=server_url) + you.search(query="breaking news", freshness=Freshness.DAY, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: freshness=DAY") ALL_METRICS.append(metrics) @@ -211,7 +211,7 @@ def test_search_with_freshness_week(self, server_url, api_key, iterations, show_ with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="renewable energy", freshness=Freshness.WEEK, server_url=server_url) + you.search(query="renewable energy", freshness=Freshness.WEEK, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: freshness=WEEK") ALL_METRICS.append(metrics) @@ -224,7 +224,7 @@ def test_search_with_country_us(self, server_url, api_key, iterations, show_deta with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="local restaurants", country=Country.US, server_url=server_url) + you.search(query="local restaurants", country=Country.US, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: country=US") ALL_METRICS.append(metrics) @@ -237,7 +237,7 @@ def test_search_with_country_gb(self, server_url, api_key, iterations, show_deta with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="football news", country=Country.GB, server_url=server_url) + you.search(query="football news", country=Country.GB, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: country=GB") ALL_METRICS.append(metrics) @@ -250,7 +250,7 @@ def test_search_with_language_en(self, server_url, api_key, iterations, show_det with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="machine learning", language=Language.EN, server_url=server_url) + you.search(query="machine learning", language=Language.EN, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: language=EN") ALL_METRICS.append(metrics) @@ -263,7 +263,7 @@ def test_search_with_language_es(self, server_url, api_key, iterations, show_det with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="tecnología", language=Language.ES, server_url=server_url) + you.search(query="tecnología", language=Language.ES, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: language=ES") ALL_METRICS.append(metrics) @@ -276,7 +276,7 @@ def test_search_with_safesearch_off(self, server_url, api_key, iterations, show_ with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="research", safesearch=SafeSearch.OFF, server_url=server_url) + you.search(query="research", safesearch=SafeSearch.OFF, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: safesearch=OFF") ALL_METRICS.append(metrics) @@ -289,7 +289,7 @@ def test_search_with_safesearch_moderate(self, server_url, api_key, iterations, with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="family content", safesearch=SafeSearch.MODERATE, server_url=server_url) + you.search(query="family content", safesearch=SafeSearch.MODERATE, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: safesearch=MODERATE") ALL_METRICS.append(metrics) @@ -302,7 +302,7 @@ def test_search_with_safesearch_strict(self, server_url, api_key, iterations, sh with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="kids learning", safesearch=SafeSearch.STRICT, server_url=server_url) + you.search(query="kids learning", safesearch=SafeSearch.STRICT, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: safesearch=STRICT") ALL_METRICS.append(metrics) @@ -315,7 +315,7 @@ def test_search_with_pagination(self, server_url, api_key, iterations, show_deta with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified(query="python tutorials", count=5, offset=2, server_url=server_url) + you.search(query="python tutorials", count=5, offset=2, server_url=server_url) metrics = measure_sdk_call(call, client, iterations, "Search: with pagination (offset=2)") ALL_METRICS.append(metrics) @@ -328,7 +328,7 @@ def test_search_with_livecrawl_web(self, server_url, api_key, iterations, show_d with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="machine learning tutorials", count=3, livecrawl=LiveCrawl.WEB, @@ -346,7 +346,7 @@ def test_search_with_livecrawl_news(self, server_url, api_key, iterations, show_ with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="tech news", count=3, livecrawl=LiveCrawl.NEWS, @@ -364,7 +364,7 @@ def test_search_with_livecrawl_all(self, server_url, api_key, iterations, show_d with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="quantum computing", count=3, livecrawl=LiveCrawl.ALL, @@ -382,7 +382,7 @@ def test_search_with_livecrawl_html(self, server_url, api_key, iterations, show_ with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="AI research", count=3, livecrawl=LiveCrawl.WEB, @@ -401,7 +401,7 @@ def test_search_with_livecrawl_markdown(self, server_url, api_key, iterations, s with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="documentation guides", count=3, livecrawl=LiveCrawl.WEB, @@ -420,7 +420,7 @@ def test_search_with_all_filters(self, server_url, api_key, iterations, show_det with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="quantum computing research", count=10, freshness=Freshness.MONTH, @@ -442,7 +442,7 @@ def test_search_with_filters_and_livecrawl(self, server_url, api_key, iterations with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="AI developments", count=5, freshness=Freshness.WEEK, @@ -463,7 +463,7 @@ def test_search_with_news_livecrawl(self, server_url, api_key, iterations, show_ with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="technology news", count=5, livecrawl=LiveCrawl.NEWS, @@ -482,7 +482,7 @@ def test_search_with_livecrawl_all_news_contents(self, server_url, api_key, iter with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.search.unified( + you.search( query="breaking tech news", count=3, livecrawl=LiveCrawl.ALL, @@ -509,7 +509,7 @@ def test_agents_express_no_tools(self, server_url, api_key, iterations, show_det with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="Teach me how to make an omelet", stream=False, @@ -528,7 +528,7 @@ def test_agents_express_with_websearch(self, server_url, api_key, iterations, sh with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="What are the latest AI developments?", stream=False, @@ -548,7 +548,7 @@ def test_agents_express_with_websearch_force(self, server_url, api_key, iteratio with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="Tell me about Python", stream=False, @@ -568,7 +568,7 @@ def test_agents_advanced_no_tools(self, server_url, api_key, iterations, show_de with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Explain quantum entanglement", stream=False, @@ -587,7 +587,7 @@ def test_agents_advanced_with_research(self, server_url, api_key, iterations, sh with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Research the latest breakthroughs in quantum computing", stream=False, @@ -610,7 +610,7 @@ def test_agents_advanced_with_research_low_effort(self, server_url, api_key, ite with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Quick research on AI", stream=False, @@ -633,7 +633,7 @@ def test_agents_advanced_with_research_high_effort(self, server_url, api_key, it with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Deep research on climate change", stream=False, @@ -656,7 +656,7 @@ def test_agents_advanced_with_research_verbosity_low(self, server_url, api_key, with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Brief summary of AI trends", stream=False, @@ -679,7 +679,7 @@ def test_agents_advanced_with_research_verbosity_high(self, server_url, api_key, with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Detailed analysis of blockchain", stream=False, @@ -702,7 +702,7 @@ def test_agents_advanced_with_compute(self, server_url, api_key, iterations, sho with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Calculate the square root of 169", stream=False, @@ -722,7 +722,7 @@ def test_agents_advanced_with_websearch_and_research(self, server_url, api_key, with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Find and research AI startups", stream=False, @@ -745,7 +745,7 @@ def test_agents_advanced_with_websearch_and_compute(self, server_url, api_key, i with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Find stock prices and calculate averages", stream=False, @@ -765,7 +765,7 @@ def test_agents_advanced_with_research_and_compute(self, server_url, api_key, it with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Research market trends and calculate growth rates", stream=False, @@ -788,7 +788,7 @@ def test_agents_advanced_with_all_tools(self, server_url, api_key, iterations, s with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=AdvancedAgentRunsRequest( input="Research tech trends, find data, and calculate statistics", stream=False, @@ -811,7 +811,7 @@ def test_agents_express_verbosity_low(self, server_url, api_key, iterations, sho with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="Brief overview of Python", stream=False, @@ -830,7 +830,7 @@ def test_agents_express_verbosity_high(self, server_url, api_key, iterations, sh with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="Detailed explanation of Python", stream=False, @@ -857,7 +857,7 @@ def test_contents_single_url_html(self, server_url, api_key, iterations, show_de with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.HTML], server_url=server_url, @@ -874,7 +874,7 @@ def test_contents_single_url_markdown(self, server_url, api_key, iterations, sho with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.MARKDOWN], server_url=server_url, @@ -891,7 +891,7 @@ def test_contents_single_url_metadata(self, server_url, api_key, iterations, sho with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.METADATA], server_url=server_url, @@ -908,7 +908,7 @@ def test_contents_multiple_formats(self, server_url, api_key, iterations, show_d with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.HTML, ContentsFormats.MARKDOWN, ContentsFormats.METADATA], server_url=server_url, @@ -925,7 +925,7 @@ def test_contents_with_crawl_timeout(self, server_url, api_key, iterations, show with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=["https://www.python.org"], formats=[ContentsFormats.HTML], crawl_timeout=30, @@ -943,7 +943,7 @@ def test_contents_multiple_urls_html(self, server_url, api_key, iterations, show with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=[ "https://www.python.org", "https://www.github.com", @@ -964,7 +964,7 @@ def test_contents_multiple_urls_markdown(self, server_url, api_key, iterations, with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=[ "https://www.python.org", "https://www.github.com", @@ -985,7 +985,7 @@ def test_contents_many_urls_html(self, server_url, api_key, iterations, show_det with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): - you.contents.generate( + you.contents( urls=[ "https://www.python.org", "https://www.github.com", diff --git a/tests/test_runs.py b/tests/test_runs.py index 3a949f9..9195a3c 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -37,7 +37,7 @@ def test_basic(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=ExpressAgentRunsRequest( input="Teach me how to make an omelet", stream=False, @@ -54,7 +54,7 @@ def test_streaming(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=ExpressAgentRunsRequest( input="Teach me how to make an omelet", stream=True, @@ -70,7 +70,7 @@ def test_with_web_search_tool(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=ExpressAgentRunsRequest( input="Summarize today's top AI research headlines.", stream=False, @@ -88,7 +88,7 @@ def test_with_research_tool(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=AdvancedAgentRunsRequest( input="Summarize today's top AI research headlines.", stream=False, @@ -107,7 +107,7 @@ def test_with_compute_tool(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=AdvancedAgentRunsRequest( input="Calculate 15 * 23 and explain the steps.", stream=False, @@ -123,7 +123,7 @@ def test_with_multiple_tools(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=AdvancedAgentRunsRequest( input="Research and calculate the square root of 169.", stream=True, @@ -146,7 +146,7 @@ def test_research_tool_configuration(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=AdvancedAgentRunsRequest( input="Research quantum computing breakthroughs.", stream=False, @@ -169,7 +169,7 @@ def test_with_uuid(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=CustomAgentRunsRequest( agent="c12fa027-424e-4002-9659-746c16e74faa", input="Teach me how to make an omelet", @@ -185,7 +185,7 @@ def test_with_tools(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=CustomAgentRunsRequest( agent="c12fa027-424e-4002-9659-746c16e74faa", input="Search for Python best practices.", @@ -205,7 +205,7 @@ def test_unauthorized(self, server_url): with You(server_url=server_url, client=client, api_key_auth="invalid") as you: with pytest.raises(AgentRuns401ResponseError): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="test", stream=False, @@ -220,7 +220,7 @@ def test_forbidden(self, server_url, api_key): # Mock server returns 403 which gets caught as a default error # In production API, this would be a more specific error type with pytest.raises(YouDefaultError): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="test", stream=False, @@ -233,7 +233,7 @@ def test_bad_request(self, server_url, api_key): with You(server_url=server_url, client=client, api_key_auth=api_key) as you: with pytest.raises((AgentRuns400ResponseError, YouDefaultError)): - you.agents.runs.create( + you.agents( request=ExpressAgentRunsRequest( input="test", stream=False, @@ -245,7 +245,7 @@ def test_empty_input(self, server_url, api_key): client = create_test_http_client("post_/v1/agents/runs") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.agents.runs.create( + res = you.agents( request=ExpressAgentRunsRequest( input="", stream=False, diff --git a/tests/test_search.py b/tests/test_search.py index 1374717..d3b3122 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -1,9 +1,7 @@ -import os import json import pytest import httpx -from tests.test_client import create_test_http_client from youdotcom import You from youdotcom.errors import ( ForbiddenResponseError, @@ -11,194 +9,24 @@ UnprocessableEntityResponseError, YouDefaultError, ) -from youdotcom.models import ( - Country, - Freshness, - LiveCrawl, - LiveCrawlFormats, - SafeSearch, -) - - -@pytest.fixture -def server_url(): - return os.getenv("TEST_SERVER_URL", "http://localhost:18080") - - -@pytest.fixture -def api_key(): - return "test-api-key" - - -class TestSearchBasic: - def test_basic_search(self, server_url, api_key): - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified(query="latest AI developments", server_url=server_url) - - assert res.results is not None - assert res.metadata is not None - assert res.metadata.query is not None - assert res.results.web or res.results.news - - -class TestSearchFilters: - def test_search_with_filters(self, server_url, api_key): - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="renewable energy", - count=10, - freshness=Freshness.WEEK, - country=Country.US, - safesearch=SafeSearch.MODERATE, - server_url=server_url, - ) - - assert res.results is not None - assert res.metadata is not None - - def test_search_with_pagination(self, server_url, api_key): - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="python programming", - count=5, - offset=1, - server_url=server_url, - ) - - assert res.results is not None - assert res.metadata is not None - - def test_search_with_livecrawl(self, server_url, api_key): - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="machine learning tutorials", - count=3, - livecrawl=LiveCrawl.WEB, - livecrawl_formats=[LiveCrawlFormats.MARKDOWN], - server_url=server_url, - ) - - assert res.results is not None - - if res.results.web: - for result in res.results.web: - if hasattr(result, "contents") and result.contents: - assert result.contents.markdown is not None - - def test_search_all_parameters(self, server_url, api_key): - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="quantum computing", - count=20, - offset=0, - freshness=Freshness.MONTH, - country=Country.GB, - safesearch=SafeSearch.STRICT, - livecrawl=LiveCrawl.WEB, - livecrawl_formats=[LiveCrawlFormats.HTML], - server_url=server_url, - ) - - assert res.results is not None - assert res.metadata is not None - - if res.results.web: - for result in res.results.web: - if hasattr(result, "contents") and result.contents: - assert result.contents.html is not None - - def test_search_news_with_livecrawl(self, server_url, api_key): - """Test that news results can have contents when livecrawl is enabled (new in 2.2.0).""" - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="technology news", - count=5, - livecrawl=LiveCrawl.NEWS, - livecrawl_formats=[LiveCrawlFormats.MARKDOWN], - server_url=server_url, - ) - - assert res.results is not None - - # News results can now have contents field when livecrawl is enabled - if res.results.news: - for news_item in res.results.news: - # Contents field is optional but should be accessible - if hasattr(news_item, "contents") and news_item.contents: - # If contents exists, it should have markdown when requested - assert news_item.contents.markdown is not None or news_item.contents.html is not None - - def test_search_livecrawl_all_with_news_contents(self, server_url, api_key): - """Test livecrawl=ALL returns contents for both web and news results.""" - client = create_test_http_client("get_/v1/search") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - res = you.search.unified( - query="breaking tech news", - count=3, - livecrawl=LiveCrawl.ALL, - livecrawl_formats=[LiveCrawlFormats.HTML], - server_url=server_url, - ) - - assert res.results is not None - - # Both web and news can have contents with livecrawl=ALL - if res.results.web: - for result in res.results.web: - if hasattr(result, "contents") and result.contents: - assert result.contents.html is not None - - if res.results.news: - for news_item in res.results.news: - if hasattr(news_item, "contents") and news_item.contents: - assert news_item.contents.html is not None - - -class TestSearchErrors: - def test_unauthorized(self, server_url): - client = create_test_http_client("get_/v1/search-unauthorized") - - with You(server_url=server_url, client=client, api_key_auth="invalid") as you: - with pytest.raises((UnauthorizedResponseError, ForbiddenResponseError, YouDefaultError)): - you.search.unified(query="test", server_url=server_url) - - def test_forbidden(self, server_url, api_key): - client = create_test_http_client("get_/v1/search-forbidden") - - with You(server_url=server_url, client=client, api_key_auth=api_key) as you: - with pytest.raises((ForbiddenResponseError, YouDefaultError)): - you.search.unified(query="test", server_url=server_url) # --------------------------------------------------------------------------- -# POST-side error tests: search_post() must raise the same consolidated -# *ResponseError classes as search.unified() (GET). Uses MockTransport -# because the mockserver has no POST /v1/search handler. +# POST-side error tests: search() must raise the same consolidated +# *ResponseError classes. Uses MockTransport because the mockserver has no +# POST /v1/search handler. # --------------------------------------------------------------------------- -class TestSearchPostErrors: - """Verify search_post() raises the consolidated *ResponseError classes. +class TestSearchErrors: + """Verify search() raises the consolidated *ResponseError classes. - The CHANGELOG documents that both Search endpoints (GET and POST) raise - the consolidated error classes. These tests lock that contract for the + The CHANGELOG documents that the Search endpoint (POST) raises the + consolidated error classes. These tests lock that contract for the POST path so a regen that mis-wires POST errors would fail CI. """ - def test_post_unauthorized(self): + def test_unauthorized(self): def handler(request): return httpx.Response( 401, @@ -210,10 +38,10 @@ def handler(request): sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="invalid") with pytest.raises((UnauthorizedResponseError, YouDefaultError)): - you.search_post(query="test") + you.search(query="test") sdk_client.close() - def test_post_forbidden(self): + def test_forbidden(self): def handler(request): return httpx.Response( 403, @@ -225,10 +53,10 @@ def handler(request): sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") with pytest.raises((ForbiddenResponseError, YouDefaultError)): - you.search_post(query="test") + you.search(query="test") sdk_client.close() - def test_post_unprocessable(self): + def test_unprocessable(self): def handler(request): return httpx.Response( 422, @@ -240,7 +68,7 @@ def handler(request): sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") with pytest.raises((UnprocessableEntityResponseError, YouDefaultError)): - you.search_post( + you.search( query="test", include_domains=["example.com"], exclude_domains=["spam.com"], @@ -248,10 +76,10 @@ def handler(request): sdk_client.close() -class TestSearchPostBoostDomains: - """Verify search_post() forwards boost_domains in the request body.""" +class TestSearchBoostDomains: + """Verify search() forwards boost_domains in the request body.""" - def test_post_boost_domains_forwarded(self): + def test_boost_domains_forwarded(self): def handler(request): body = json.loads(request.content) assert "boost_domains" in body @@ -272,10 +100,9 @@ def handler(request): transport = httpx.MockTransport(handler) sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") - res = you.search_post( + res = you.search( query="Python type hints", boost_domains=["python.org", "realpython.com"], ) assert res.results is not None sdk_client.close() - From 651d9f2cd4d6f552bd4764a3ee44e44e681063f4 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 18:10:01 -0700 Subject: [PATCH 22/35] Add POST /v1/search handler to mock server The performance tests now call you.search() (POST /v1/search) instead of you.search.unified() (GET /v1/search). The mock server only had a GET handler, causing 405 errors. Added a POST handler that returns the same SearchResponse shape. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../internal/handler/generated_handlers.go | 1 + .../internal/handler/pathpostv1search.go | 59 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/mockserver/internal/handler/pathpostv1search.go diff --git a/tests/mockserver/internal/handler/generated_handlers.go b/tests/mockserver/internal/handler/generated_handlers.go index e2bdae4..f53f077 100644 --- a/tests/mockserver/internal/handler/generated_handlers.go +++ b/tests/mockserver/internal/handler/generated_handlers.go @@ -13,6 +13,7 @@ import ( func GeneratedHandlers(ctx context.Context, dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) []*GeneratedHandler { return []*GeneratedHandler{ NewGeneratedHandler(ctx, http.MethodGet, "/v1/search", pathGetV1Search(dir, rt)), + NewGeneratedHandler(ctx, http.MethodPost, "/v1/search", pathPostV1Search(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/agents/runs", pathPostV1AgentsRuns(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/contents", pathPostV1Contents(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/research", pathPostV1Research(dir, rt)), diff --git a/tests/mockserver/internal/handler/pathpostv1search.go b/tests/mockserver/internal/handler/pathpostv1search.go new file mode 100644 index 0000000..6afcf48 --- /dev/null +++ b/tests/mockserver/internal/handler/pathpostv1search.go @@ -0,0 +1,59 @@ +package handler + +import ( + "log" + "mockserver/internal/handler/assert" + "mockserver/internal/logging" + "mockserver/internal/tracking" + "net/http" +) + +func pathPostV1Search(dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + _ = req.Header.Get("x-speakeasy-test-name") + _ = req.Header.Get("x-speakeasy-test-instance-id") + + if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + if err := assert.HeaderExists(req, "User-Agent"); err != nil { + log.Printf("assertion error: %s\n", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "results": { + "web": [ + { + "url": "https://you.com", + "title": "The World's Greatest Search Engine!", + "description": "Search on YDC", + "snippets": ["I'm an AI assistant that helps you get more done."], + "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", + "page_age": "2025-06-25T11:41:00Z", + "favicon_url": "https://someurl.com/favicon" + } + ], + "news": [ + { + "title": "You.com becomes the backbone of the EU's AI strategy", + "description": "You.com becomes the backbone of the EU's AI strategy.", + "page_age": "2025-06-25T11:41:00Z", + "thumbnail_url": "https://www.somethumbnailsite.com/thumbnail.jpg", + "url": "https://www.you.com/news/eu-ai-strategy-youcom" + } + ] + }, + "metadata": { + "search_uuid": "942ccbdd-7705-4d9c-9d37-4ef386658e90", + "query": "Your query", + "latency": 0.123 + } + }`)) + } +} From e6e52637f32c16cd581ad44bfb4f361fe53e1ca1 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 18:18:32 -0700 Subject: [PATCH 23/35] Fix AnswerRequestBody docs: mark domain lists as Optional Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/models/answerrequestbody.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/models/answerrequestbody.md b/docs/models/answerrequestbody.md index 25cb9f5..2015fc9 100644 --- a/docs/models/answerrequestbody.md +++ b/docs/models/answerrequestbody.md @@ -11,6 +11,6 @@ Request body for `POST /v1/answer`. | `freshness` | [Optional[models.FreshnessValue]](../models/freshnessvalue.md) | :heavy_minus_sign: | Specifies the freshness of the results. One of `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD`. | | `country` | [Optional[models.Country]](../models/country.md) | :heavy_minus_sign: | A supported country code that determines the geographical focus of the web results. | | `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | A supported BCP 47 language tag that determines the language of the web results. | -| `include_domains` | List[*str*] | :heavy_minus_sign: | Domains to exclusively include. Cannot combine with `exclude_domains` or `boost_domains`. Max 500. | -| `exclude_domains` | List[*str*] | :heavy_minus_sign: | Domains to exclude. Cannot combine with `include_domains`. Can combine with `boost_domains`. Max 500. | -| `boost_domains` | List[*str*] | :heavy_minus_sign: | Domains to prefer in ranking. Cannot combine with `include_domains`. Can combine with `exclude_domains`. Max 500. | +| `include_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to exclusively include. Cannot combine with `exclude_domains` or `boost_domains`. Max 500. | +| `exclude_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to exclude. Cannot combine with `include_domains`. Can combine with `boost_domains`. Max 500. | +| `boost_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to prefer in ranking. Cannot combine with `include_domains`. Can combine with `exclude_domains`. Max 500. | From 4b92e82485c4c3b7e0cce5e77854c065396369bb Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 18:30:33 -0700 Subject: [PATCH 24/35] Fix stale references across docs, examples, and tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drift check found and fixed: - USAGE.md: search_post → search - examples/api-example-calls.py: all sub-SDK calls → direct methods, boost_domains changed from comma-separated string to list (POST API) - tests/test_live.py: renamed test_search_post → test_search - src/youdotcom/search_helpers.py: docstring updated to reference you.search() instead of search_post/search.unified - docs/sdks/you/README.md: added ## answer, ## agents, ## contents sections with examples (anchor links were broken) - docs/models/contentsmetadata.md: api.ydc-index.io → api.you.com - .agents/skills/generate-sdk-and-open-pr/SKILL.md: updated host guidance (ydc-index.io → api.you.com), method names (search_post → search) Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../skills/generate-sdk-and-open-pr/SKILL.md | 18 +++---- USAGE.md | 4 +- docs/models/contentsmetadata.md | 2 +- docs/sdks/you/README.md | 54 +++++++++++++++++++ examples/api-example-calls.py | 20 +++---- src/youdotcom/search_helpers.py | 4 +- tests/test_live.py | 4 +- 7 files changed, 80 insertions(+), 26 deletions(-) diff --git a/.agents/skills/generate-sdk-and-open-pr/SKILL.md b/.agents/skills/generate-sdk-and-open-pr/SKILL.md index ff95852..ae08df4 100644 --- a/.agents/skills/generate-sdk-and-open-pr/SKILL.md +++ b/.agents/skills/generate-sdk-and-open-pr/SKILL.md @@ -301,18 +301,18 @@ grep -rl "YOU_API_KEY_AUTH" --include="*.py" --include="*.md" . | grep -v __pyca # in the fallback precedence. ``` -#### 4i-2. Verify server URLs (do NOT change search/contents URLs) +#### 4i-2. Verify server URLs -The OpenAPI specs for search and contents use `https://ydc-index.io` as the server URL. This is correct and documented at `you.com/docs/api-reference/search/v1-search` (the page explicitly shows `GET https://ydc-index.io/v1/search`). The `api.you.com` host is a free MCP-only proxy (`/v1/agents/search`, 100 searches/day, IP-tracked) — the SDK should NOT use it for search or contents. +The OpenAPI specs for search and contents use `https://api.you.com` as the server URL. This is the canonical host for the SDK and is documented at `you.com/docs/api-reference/search/v1-search`. -**Verify** (do not change) that these files still have `ydc-index.io`: +**Verify** (do not change) that these files still have `api.you.com`: ```bash -grep "ydc-index.io" src/youdotcom/models/searchop.py src/youdotcom/models/searchpostop.py src/youdotcom/models/contentsop.py -# All three should show "https://ydc-index.io" +grep "api.you.com" src/youdotcom/models/searchop.py src/youdotcom/models/searchpostop.py src/youdotcom/models/contentsop.py +# All three should show "https://api.you.com" ``` -The base `SERVERS` in `src/youdotcom/sdkconfiguration.py` should remain `https://api.you.com` (used by research, finance_research, agents). +The base `SERVERS` in `src/youdotcom/sdkconfiguration.py` should also remain `https://api.you.com` (used by all endpoints: search, contents, research, finance_research, agents). #### 4i-3. Preserve and verify hand-maintained files @@ -412,7 +412,7 @@ Speakeasy assembles per-parameter `example` values into one combined request. Fo - `boost_domains` **cannot** be combined with `include_domains` (returns `422`). - `exclude_domains` + `boost_domains` **is** valid. -After every regen, grep the lead Search examples in `USAGE.md` and `README.md` (specifically the `` blocks) to confirm no `search_post`/`search.unified` example combines all three of `include_domains`, `exclude_domains`, and `boost_domains`: +After every regen, grep the lead Search examples in `USAGE.md` and `README.md` (specifically the `` blocks) to confirm no `search` example combines all three of `include_domains`, `exclude_domains`, and `boost_domains`: ```bash # Each occurrence with all three is a bug — drop include_domains (keep @@ -424,11 +424,11 @@ grep -nE 'include_domains=\[' USAGE.md README.md The long-term fix lives upstream: add a request-level `example` block on `SearchRequestBody` / `SearchRequest` in `overlays/python_overlay.yaml` (or the front-end OpenAPI specs) that uses a single valid pair, so Speakeasy prefers that example instead of concatenating per-field ones. Track that as a follow-up; the hand-fix above is what keeps 2.4.0 correct in the meantime. -Also scan for the `RetryConfig(...)` positional-after-kwargs regression that Speakeasy can produce when `search_post` is the lead example operation: +Also scan for the `RetryConfig(...)` positional-after-kwargs regression that Speakeasy can produce when `search` is the lead example operation: ```bash # If you see ", RetryConfig(...)" or similar after a keyword argument in a -# search_post example, the generated Python is a SyntaxError. Fix by +# search example, the generated Python is a SyntaxError. Fix by # passing `retries=RetryConfig(...)`. grep -nE ', RetryConfig\(' README.md USAGE.md ``` diff --git a/USAGE.md b/USAGE.md index 37e0e9d..17f7411 100644 --- a/USAGE.md +++ b/USAGE.md @@ -9,7 +9,7 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = you.search_post(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = you.search(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ @@ -37,7 +37,7 @@ async def main(): api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: - res = await you.search_post_async(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ + res = await you.search_async(query="What are the latest geopolitical updates from India", count=10, language=models.Language.EN, exclude_domains=[ "spam-site.com", "other-site.com", ], boost_domains=[ diff --git a/docs/models/contentsmetadata.md b/docs/models/contentsmetadata.md index 6050bc0..cfac725 100644 --- a/docs/models/contentsmetadata.md +++ b/docs/models/contentsmetadata.md @@ -8,4 +8,4 @@ Metadata about the web page. Only returned when 'metadata' is included in the fo | Field | Type | Required | Description | Example | | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------- | | `site_name` | *OptionalNullable[str]* | :heavy_minus_sign: | The OpenGraph site name of the web page. | You.com | -| `favicon_url` | *Optional[str]* | :heavy_minus_sign: | The URL of the favicon of the web page's domain. | https://api.ydc-index.io/favicon?domain=you.com&size=128 | \ No newline at end of file +| `favicon_url` | *Optional[str]* | :heavy_minus_sign: | The URL of the favicon of the web page's domain. | https://api.you.com/favicon?domain=you.com&size=128 | \ No newline at end of file diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index e3aa3a6..2edb793 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -26,6 +26,43 @@ Comprehensive API for You.com services: * [stream_research_task](#stream_research_task) - Stream updates for a background research task * [finance_research](#finance_research) - Returns comprehensive finance-grade research answers with multi-step reasoning +## answer + +Returns a synthesized answer with citations from web search results. + +### Example Usage + +```python +import os +from youdotcom import You + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + res = you.answer(query="What is the capital of France?") + print(res) +``` + +## agents + +Execute queries using You.com's AI agents (Express, Advanced, or Custom). + +### Example Usage + +```python +import os +from youdotcom import You, models + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + res = you.agents(request=models.ExpressAgentRunsRequest( + agent=models.AgentType.EXPRESS, + input="What are the latest AI developments?", + )) + print(res) +``` + ## search This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. @@ -235,6 +272,23 @@ with You( | errors.InternalServerErrorResponse | 500 | application/json | | errors.YouDefaultError | 4XX, 5XX | \*/\* | +## contents + +Returns the HTML or Markdown of target web pages. + +### Example Usage + +```python +import os +from youdotcom import You, models + +with You( + api_key_auth=os.getenv("YDC_API_KEY", ""), +) as you: + res = you.contents(urls=["https://example.com"], formats=[models.ContentsFormats.MARKDOWN]) + print(res) +``` + ## research Research goes beyond a single web search. In response to your question, it runs multiple searches, reads through the sources, and synthesizes everything into a thorough, well-cited answer. Use it when a question is too complex for a simple lookup, and when you need a response you can actually trust and verify. diff --git a/examples/api-example-calls.py b/examples/api-example-calls.py index 9136685..692014f 100755 --- a/examples/api-example-calls.py +++ b/examples/api-example-calls.py @@ -60,7 +60,7 @@ def express_batch_request(): assert you is not None, "SDK client not initialized" - results = you.agents.runs.create(request=ExpressAgentRunsRequest( + results = you.agents(request=ExpressAgentRunsRequest( input="What is the capital of France?", stream=False, tools=[ @@ -90,7 +90,7 @@ def express_streaming_request(): assert you is not None, "SDK client not initialized" - response = you.agents.runs.create(request=ExpressAgentRunsRequest( + response = you.agents(request=ExpressAgentRunsRequest( input="Restaurants in San Francisco", stream=True, tools=[ @@ -159,7 +159,7 @@ def advanced_batch_request(): ] ) - results = you.agents.runs.create(request=request) + results = you.agents(request=request) # Access the results - check if it's a batch response if isinstance(results, AgentRunsBatchResponse): @@ -195,7 +195,7 @@ def custom_batch_request(): ) try: - results = you.agents.runs.create(request=request) + results = you.agents(request=request) print(results) except Exception as e: print(f"Error: {e}") @@ -210,7 +210,7 @@ def search_request(): assert you is not None, "SDK client not initialized" - results = you.search.unified( + results = you.search( query="artificial intelligence in farming", count=1, livecrawl=LiveCrawl.WEB, @@ -242,7 +242,7 @@ def content_request(): # Example 1: Get markdown content print("Example 1: Fetching markdown content...") - results = you.contents.generate( + results = you.contents( urls=["https://you.com"], formats=[ContentsFormats.MARKDOWN] ) @@ -257,7 +257,7 @@ def content_request(): # Example 2: Get multiple formats including metadata (json+ld, opengraph info) print("Example 2: Fetching HTML + metadata...") - results = you.contents.generate( + results = you.contents( urls=["https://you.com"], formats=[ContentsFormats.HTML, ContentsFormats.METADATA], crawl_timeout=30 # Optional: set custom timeout (1-60 seconds) @@ -487,10 +487,10 @@ def search_request_with_boost(): assert you is not None, "SDK client not initialized" - results = you.search.unified( + results = you.search( query="latest advances in fusion energy research", count=5, - boost_domains="nature.com,science.org,arxiv.org", + boost_domains=["nature.com", "science.org", "arxiv.org"], ) print("Top results:") @@ -509,7 +509,7 @@ def content_request_with_max_age(): assert you is not None, "SDK client not initialized" - results = you.contents.generate( + results = you.contents( urls=["https://example.com/page"], formats=[ContentsFormats.MARKDOWN], crawl_timeout=20, diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py index c0e492a..c34c6d9 100644 --- a/src/youdotcom/search_helpers.py +++ b/src/youdotcom/search_helpers.py @@ -1,7 +1,7 @@ """Hand-maintained search helpers targeting ``/v1/agents/search``. This module is hand-maintained (the SDK is no longer generated by Speakeasy). -It mirrors the ``search_post`` request machinery but POSTs to +It mirrors the ``you.search()`` request machinery but POSTs to ``/v1/agents/search`` instead of ``/v1/search``. The agents-search endpoint is a proxy that: @@ -10,7 +10,7 @@ ``livecrawl`` not allowed. Returns ``402`` on any limit. Use this as the default search entrypoint for skills, plugins, and MCP tools. -The generated ``search.unified`` / ``search_post`` remain available for callers +The ``you.search()`` method remains available for callers who need the raw ``/v1/search`` endpoint. """ diff --git a/tests/test_live.py b/tests/test_live.py index 10e6eae..4697056 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -463,8 +463,8 @@ class TestLiveSearchBoostDomains: other domains — for a more permissive alternative to `include_domains`. """ - def test_search_post_boost_domains_list(self, you_client): - """search_post accepts a Python list of boost domains.""" + def test_search_boost_domains_list(self, you_client): + """search accepts a Python list of boost domains.""" with you_client as you: res = you.search( query="Python type hints vs TypeScript inference", From 36bb2cb38466c388f3efcd2bbff18d1a9c32fa01 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 19:20:35 -0700 Subject: [PATCH 25/35] Fix review findings: dead code, server_url bypass, type annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2: search()/search_async() now use self._get_url(None, None) instead of hardcoded SEARCH_POST_OP_SERVERS[0], so custom server_url passed to You() is respected (was silently ignored for search only). P1: Removed dead code: - Unused `import importlib` and `TYPE_CHECKING` from sdk.py - Deleted _hooks/registration.py (init_hooks was a no-op still called on every You() construction — removed call from sdkhooks.py) - Deleted models/answerop.py (ANSWER_OP_SERVERS never used by answer()) P3: answer()/answer_async() country/language annotations changed from Optional[str] to Optional[models.Country]/Optional[models.Language] to match search() and reflect the actual enum constraint. P3: Updated stale Speakeasy docstring in research_helpers.py. 54 unit tests pass, 117 mock server tests pass, mypy clean (106 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/_hooks/__init__.py | 1 - src/youdotcom/_hooks/registration.py | 11 ----------- src/youdotcom/_hooks/sdkhooks.py | 2 -- src/youdotcom/models/__init__.py | 3 --- src/youdotcom/models/answerop.py | 6 ------ src/youdotcom/research_helpers.py | 3 +-- src/youdotcom/sdk.py | 14 ++++++-------- 7 files changed, 7 insertions(+), 33 deletions(-) delete mode 100644 src/youdotcom/_hooks/registration.py delete mode 100644 src/youdotcom/models/answerop.py diff --git a/src/youdotcom/_hooks/__init__.py b/src/youdotcom/_hooks/__init__.py index f06758a..2fc0799 100644 --- a/src/youdotcom/_hooks/__init__.py +++ b/src/youdotcom/_hooks/__init__.py @@ -2,4 +2,3 @@ from .sdkhooks import * from .types import * -from .registration import * diff --git a/src/youdotcom/_hooks/registration.py b/src/youdotcom/_hooks/registration.py deleted file mode 100644 index ef98ff2..0000000 --- a/src/youdotcom/_hooks/registration.py +++ /dev/null @@ -1,11 +0,0 @@ -from .types import Hooks - - -def init_hooks(hooks: Hooks): - """Register SDK hooks. - - The user-agent is set directly from ``sdk_configuration.user_agent`` in - ``BaseSDK._build_request`` — no hook needed. Integrations that want a - custom UA simply override ``client.sdk_configuration.user_agent``. - """ - pass diff --git a/src/youdotcom/_hooks/sdkhooks.py b/src/youdotcom/_hooks/sdkhooks.py index b2aadbd..d9d8ffb 100644 --- a/src/youdotcom/_hooks/sdkhooks.py +++ b/src/youdotcom/_hooks/sdkhooks.py @@ -11,7 +11,6 @@ AfterErrorHook, Hooks, ) -from .registration import init_hooks from typing import List, Optional, Tuple from youdotcom.sdkconfiguration import SDKConfiguration @@ -22,7 +21,6 @@ def __init__(self) -> None: self.before_request_hooks: List[BeforeRequestHook] = [] self.after_success_hooks: List[AfterSuccessHook] = [] self.after_error_hooks: List[AfterErrorHook] = [] - init_hooks(self) def register_sdk_init_hook(self, hook: SDKInitHook) -> None: self.sdk_init_hooks.append(hook) diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index 4f99729..573543d 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -14,7 +14,6 @@ WorkflowConfigTypedDict, ) from .answercitation import AnswerCitation - from .answerop import ANSWER_OP_SERVERS from .answerrequestbody import AnswerRequestBody from .answerresponse import AnswerResponse, AnswerResults from .answersearchresult import AnswerSearchResult @@ -202,7 +201,6 @@ "AGENTS_RUNS_OP_SERVERS", "AdvancedAgentRunsRequest", "AdvancedAgentRunsRequestTypedDict", - "ANSWER_OP_SERVERS", "AnswerCitation", "AnswerRequestBody", "AnswerResponse", @@ -377,7 +375,6 @@ "ToolTypedDict": ".advancedagentrunsrequest", "WorkflowConfig": ".advancedagentrunsrequest", "WorkflowConfigTypedDict": ".advancedagentrunsrequest", - "ANSWER_OP_SERVERS": ".answerop", "AnswerCitation": ".answercitation", "AnswerRequestBody": ".answerrequestbody", "AnswerResponse": ".answerresponse", diff --git a/src/youdotcom/models/answerop.py b/src/youdotcom/models/answerop.py deleted file mode 100644 index d8e71e4..0000000 --- a/src/youdotcom/models/answerop.py +++ /dev/null @@ -1,6 +0,0 @@ -from __future__ import annotations - - -ANSWER_OP_SERVERS = [ - "https://api.you.com", -] diff --git a/src/youdotcom/research_helpers.py b/src/youdotcom/research_helpers.py index a4b0078..e56bfbf 100644 --- a/src/youdotcom/research_helpers.py +++ b/src/youdotcom/research_helpers.py @@ -1,7 +1,6 @@ """Hand-maintained research workflow helpers. -This module is NOT regenerated by Speakeasy. It adds convenience helpers on -top of the auto-generated research endpoints: +Convenience helpers on top of the research endpoints: - ``research_background`` / ``research_background_async``: Submit a research task with ``background=True`` and return the ``TaskResponse`` directly so diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 25a9047..a4e6516 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -6,7 +6,6 @@ from .utils.logger import Logger, get_default_logger from .utils.retries import RetryConfig import httpx -import importlib from typing import ( Any, Callable, @@ -15,7 +14,6 @@ List, Mapping, Optional, - TYPE_CHECKING, Union, cast, ) @@ -164,8 +162,8 @@ def answer( freshness: Optional[ Union[models.FreshnessValue, models.FreshnessValueTypedDict] ] = None, - country: Optional[str] = None, - language: Optional[str] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -312,8 +310,8 @@ async def answer_async( freshness: Optional[ Union[models.FreshnessValue, models.FreshnessValueTypedDict] ] = None, - country: Optional[str] = None, - language: Optional[str] = None, + country: Optional[models.Country] = None, + language: Optional[models.Language] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -1024,7 +1022,7 @@ def search( if server_url is not None: base_url = server_url else: - base_url = models.SEARCH_POST_OP_SERVERS[0] + base_url = self._get_url(None, None) request = models.SearchRequestBody( query=query, @@ -1182,7 +1180,7 @@ async def search_async( if server_url is not None: base_url = server_url else: - base_url = models.SEARCH_POST_OP_SERVERS[0] + base_url = self._get_url(None, None) request = models.SearchRequestBody( query=query, From 6d4e61d7577c3af02b6a524dd6002939acb4255d Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 19:28:43 -0700 Subject: [PATCH 26/35] Bump version to 2.6.0, update dev dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version: 2.5.0 → 2.6.0 Dev dependency updates (runtime deps unchanged — already at latest): - mypy: 1.15.0 → >=2.3.0 - pylint: 3.2.3 → >=4.0.0 - pytest: >=8.0.0 → >=9.0.0 - pytest-asyncio: >=0.24.0 → >=1.0.0 Runtime dependencies verified current: - httpx 0.28.1 (latest stable, 1.0 in dev preview) - httpcore 1.0.9 (latest) - pydantic 2.13.4 (latest, floor >=2.11.2 adequate) Changed pins to floors (>=) for dev deps to avoid breaking contributor environments on minor releases. 54 unit tests pass, 117 mock server tests pass, mypy 2.3.0 clean. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++++ MIGRATION.md | 2 +- pyproject.toml | 12 ++++++------ src/youdotcom/_version.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b75052e..56bf553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,10 @@ Accessing `you.agents`, `you.search`, or `you.contents` as attributes will now f - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. - **Removed `YDCUserAgentOverrideHook`**: The hook existed to rewrite Speakeasy's default UA (`speakeasy-sdk/python ...`) to `youdotcom-python-sdk/{version}`. Now that `__user_agent__` is already `youdotcom-python-sdk/{version}`, `BaseSDK._build_request` sets it directly — the hook was a no-op. Integrations that need a custom UA still just set `client.sdk_configuration.user_agent`. - **`__user_agent__` derived from resolved `__version__`**: The user-agent string is now built from the package's resolved version at runtime rather than a hardcoded value. +- **Dead code removal**: Deleted `_hooks/registration.py` (no-op `init_hooks`), `models/answerop.py` (unused `ANSWER_OP_SERVERS`), unused `importlib` and `TYPE_CHECKING` imports from `sdk.py`. +- **`search()` server_url fix**: `search()` and `search_async()` now use `self._get_url(None, None)` instead of hardcoded `SEARCH_POST_OP_SERVERS[0]`, so custom `server_url` passed to `You()` is respected (previously ignored for search only). +- **`answer()` type annotations**: `country` and `language` parameters changed from `Optional[str]` to `Optional[models.Country]` / `Optional[models.Language]` to match `search()` and reflect the actual enum constraint. String values are still accepted and normalized via `.upper()`. +- **Dev dependencies updated**: mypy `1.15.0` → `>=2.3.0`, pylint `3.2.3` → `>=4.0.0`, pytest floor `>=8.0.0` → `>=9.0.0`, pytest-asyncio floor `>=0.24.0` → `>=1.0.0`. Runtime dependencies (httpx, httpcore, pydantic) unchanged — already at latest stable. ## [2.5.0] - 2026-07-20 diff --git a/MIGRATION.md b/MIGRATION.md index f780bee..d888a99 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,6 +1,6 @@ # Migration Guide -## 2.5.0 → Unreleased (major version) +## 2.5.0 → 2.6.0 (major version) > **This is a major version release with breaking changes.** Sub-SDKs have been removed and methods are now direct on the `You` class. Update your code before upgrading. diff --git a/pyproject.toml b/pyproject.toml index e14db01..d3c2916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "youdotcom" -version = "2.5.0" +version = "2.6.0" description = "The official You.com Python SDK." authors = [{ name = "You.com" },] readme = "README.md" @@ -14,11 +14,11 @@ license = { text = "Apache-2.0" } [dependency-groups] dev = [ - "mypy ==1.15.0", - "pylint ==3.2.3", - "pyright ==1.1.398", - "pytest >=8.0.0", - "pytest-asyncio >=0.24.0", + "mypy >=2.3.0", + "pylint >=4.0.0", + "pyright >=1.1.398", + "pytest >=9.0.0", + "pytest-asyncio >=1.0.0", ] [tool.setuptools.packages.find] diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 7157be8..7e641ce 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -2,7 +2,7 @@ import importlib.metadata __title__: str = "youdotcom" -__version__: str = "2.5.0" +__version__: str = "2.6.0" __openapi_doc_version__: str = "1.0.0" try: From 84347ac1ae8b9715017d629f140a09e97bb8e29b Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 19:48:38 -0700 Subject: [PATCH 27/35] Update all surfaces for 2.6.0 release - CHANGELOG: [Unreleased] to [2.6.0] - 2026-08-04 - uv.lock: regenerated (youdotcom 2.5.0 to 2.6.0, mypy/pytest/pylint updated) - CI workflow: fix dev dependency install for [dependency-groups] - PR description: updated with version, review fixes, dependency updates Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 +- CHANGELOG.md | 2 +- uv.lock | 244 ++++++++++++++++++++++++++++++------- 3 files changed, 205 insertions(+), 45 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1c0689c..7f99a73 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev]" - pip install pytest pytest-asyncio + pip install -e . + pip install mypy pylint pyright pytest pytest-asyncio - name: Build and start mock server working-directory: tests/mockserver diff --git a/CHANGELOG.md b/CHANGELOG.md index 56bf553..d627442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the You.com Python SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [2.6.0] - 2026-08-04 ### Breaking Changes diff --git a/uv.lock b/uv.lock index 4307a77..ecb1231 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12'", + "python_full_version >= '3.15'", + "python_full_version >= '3.12' and python_full_version < '3.15'", "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -30,16 +31,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "astroid" -version = "3.2.4" +version = "4.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/53/1067e1113ecaf58312357f2cd93063674924119d80d173adc3f6f2387aa2/astroid-3.2.4.tar.gz", hash = "sha256:0e14202810b30da1b735827f78f5157be2bbd4a7a59b7707ca0bfc2fb4c0063a", size = 397576, upload-time = "2024-07-20T12:57:43.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/96/b32bbbb46170a1c8b8b1f28c794202e25cfe743565e9d3469b8eb1e0cc05/astroid-3.2.4-py3-none-any.whl", hash = "sha256:413658a61eeca6202a59231abb473f932038fbcbf1666587f66d482083413a25", size = 276348, upload-time = "2024-07-20T12:57:40.886Z" }, + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, ] [[package]] @@ -83,7 +125,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -154,6 +196,93 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310, upload-time = "2023-12-13T20:37:23.244Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + [[package]] name = "mccabe" version = "0.7.0" @@ -165,40 +294,62 @@ wheels = [ [[package]] name = "mypy" -version = "1.15.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, { name = "mypy-extensions" }, + { name = "pathspec" }, { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717, upload-time = "2025-02-05T03:50:34.655Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/68/f8/65a7ce8d0e09b6329ad0c8d40330d100ea343bd4dd04c4f8ae26462d0a17/mypy-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:979e4e1a006511dacf628e36fadfecbcc0160a8af6ca7dad2f5025529e082c13", size = 10738433, upload-time = "2025-02-05T03:49:29.145Z" }, - { url = "https://files.pythonhosted.org/packages/b4/95/9c0ecb8eacfe048583706249439ff52105b3f552ea9c4024166c03224270/mypy-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c4bb0e1bd29f7d34efcccd71cf733580191e9a264a2202b0239da95984c5b559", size = 9861472, upload-time = "2025-02-05T03:49:16.986Z" }, - { url = "https://files.pythonhosted.org/packages/84/09/9ec95e982e282e20c0d5407bc65031dfd0f0f8ecc66b69538296e06fcbee/mypy-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be68172e9fd9ad8fb876c6389f16d1c1b5f100ffa779f77b1fb2176fcc9ab95b", size = 11611424, upload-time = "2025-02-05T03:49:46.908Z" }, - { url = "https://files.pythonhosted.org/packages/78/13/f7d14e55865036a1e6a0a69580c240f43bc1f37407fe9235c0d4ef25ffb0/mypy-1.15.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7be1e46525adfa0d97681432ee9fcd61a3964c2446795714699a998d193f1a3", size = 12365450, upload-time = "2025-02-05T03:50:05.89Z" }, - { url = "https://files.pythonhosted.org/packages/48/e1/301a73852d40c241e915ac6d7bcd7fedd47d519246db2d7b86b9d7e7a0cb/mypy-1.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2e2c2e6d3593f6451b18588848e66260ff62ccca522dd231cd4dd59b0160668b", size = 12551765, upload-time = "2025-02-05T03:49:33.56Z" }, - { url = "https://files.pythonhosted.org/packages/77/ba/c37bc323ae5fe7f3f15a28e06ab012cd0b7552886118943e90b15af31195/mypy-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:6983aae8b2f653e098edb77f893f7b6aca69f6cffb19b2cc7443f23cce5f4828", size = 9274701, upload-time = "2025-02-05T03:49:38.981Z" }, - { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338, upload-time = "2025-02-05T03:50:17.287Z" }, - { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540, upload-time = "2025-02-05T03:49:51.21Z" }, - { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051, upload-time = "2025-02-05T03:50:20.885Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751, upload-time = "2025-02-05T03:49:42.408Z" }, - { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783, upload-time = "2025-02-05T03:49:07.707Z" }, - { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618, upload-time = "2025-02-05T03:49:54.581Z" }, - { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981, upload-time = "2025-02-05T03:50:28.25Z" }, - { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175, upload-time = "2025-02-05T03:50:13.411Z" }, - { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675, upload-time = "2025-02-05T03:50:31.421Z" }, - { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020, upload-time = "2025-02-05T03:48:48.705Z" }, - { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582, upload-time = "2025-02-05T03:49:03.628Z" }, - { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614, upload-time = "2025-02-05T03:50:00.313Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592, upload-time = "2025-02-05T03:48:55.789Z" }, - { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611, upload-time = "2025-02-05T03:48:44.581Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443, upload-time = "2025-02-05T03:49:25.514Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541, upload-time = "2025-02-05T03:49:57.623Z" }, - { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348, upload-time = "2025-02-05T03:48:52.361Z" }, - { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648, upload-time = "2025-02-05T03:49:11.395Z" }, - { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777, upload-time = "2025-02-05T03:50:08.348Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [[package]] @@ -228,6 +379,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -390,7 +550,7 @@ wheels = [ [[package]] name = "pylint" -version = "3.2.3" +version = "4.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "astroid" }, @@ -402,9 +562,9 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "tomlkit" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/e9/60280b14cc1012794120345ce378504cf17409e38cd88f455dc24e0ad6b5/pylint-3.2.3.tar.gz", hash = "sha256:02f6c562b215582386068d52a30f520d84fdbcf2a95fc7e855b816060d048b60", size = 1506739, upload-time = "2024-06-06T14:19:17.955Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/1d/3bb57f303701549550d74bf7ced2b07412be97125c167a0c9d216aa9f762/pylint-4.0.6.tar.gz", hash = "sha256:52f19191bee08bf103f9705ad1a0ece4aa5a0a4ef2bdcbd969375a1e6f6579d5", size = 1585588, upload-time = "2026-06-14T14:43:26.772Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/d3/d346f779cbc9384d8b805a7557b5f2b8ee9f842bffebec9fc6364d6ae183/pylint-3.2.3-py3-none-any.whl", hash = "sha256:b3d7d2708a3e04b4679e02d99e72329a8b7ee8afb8d04110682278781f889fa8", size = 519244, upload-time = "2024-06-06T14:19:13.228Z" }, + { url = "https://files.pythonhosted.org/packages/ab/da/acb2e7d4dbd2dfb792d38c0d850481f29ad7049b356d23f56c687d35203b/pylint-4.0.6-py3-none-any.whl", hash = "sha256:d11a0e1fdb7b1cd46ec5d6fc78fee8b95f28695b2d6140e5809925f61e32ea54", size = 538389, upload-time = "2026-06-14T14:43:24.873Z" }, ] [[package]] @@ -533,7 +693,7 @@ wheels = [ [[package]] name = "youdotcom" -version = "2.5.0" +version = "2.6.0" source = { editable = "." } dependencies = [ { name = "httpcore" }, @@ -559,9 +719,9 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "mypy", specifier = "==1.15.0" }, - { name = "pylint", specifier = "==3.2.3" }, - { name = "pyright", specifier = "==1.1.398" }, - { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "mypy", specifier = ">=2.3.0" }, + { name = "pylint", specifier = ">=4.0.0" }, + { name = "pyright", specifier = ">=1.1.398" }, + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, ] From 82835a25a295ac39aade2e642f38d6d6975c81ba Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 20:33:07 -0700 Subject: [PATCH 28/35] Fix P1 review findings: Optional model fields, dead code, docstring P1: Answer model List fields changed to Optional to match SearchResponse pattern. AnswerResults.web, AnswerResponse.citations, AnswerResponse.results, AnswerCitation.excerpts, AnswerSearchResult.snippets all now default to None instead of empty list. Prevents ValidationError when API returns null. P1: Deleted dead searchpostop.py (SEARCH_POST_OP_SERVERS no longer used after search() switched to _get_url()). Removed all references from models/__init__.py. P3: Fixed PaymentRequiredResponseErrorData docstring (referenced non-existent UpgradeRequiredResponse class). P3: Removed dead Speakeasy test-header reads from Go mock handler. 54 unit tests pass, 117 mock server tests pass, mypy clean (105 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/youdotcom/errors/paymentrequired_response_error.py | 2 +- src/youdotcom/models/__init__.py | 3 --- src/youdotcom/models/answercitation.py | 4 ++-- src/youdotcom/models/answerresponse.py | 6 +++--- src/youdotcom/models/answersearchresult.py | 2 +- src/youdotcom/models/searchpostop.py | 8 -------- tests/mockserver/internal/handler/pathpostv1search.go | 3 --- 7 files changed, 7 insertions(+), 21 deletions(-) delete mode 100644 src/youdotcom/models/searchpostop.py diff --git a/src/youdotcom/errors/paymentrequired_response_error.py b/src/youdotcom/errors/paymentrequired_response_error.py index e460f65..08d1a2b 100644 --- a/src/youdotcom/errors/paymentrequired_response_error.py +++ b/src/youdotcom/errors/paymentrequired_response_error.py @@ -7,7 +7,7 @@ class PaymentRequiredResponseErrorData(BaseModel): - r"""Body of a 402 ``UpgradeRequiredResponse`` — returned when the account + r"""Body of a 402 response — returned when the account cannot make paid API requests (free-tier limit exceeded, insufficient credits). """ error: Optional[str] = None diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index 573543d..6955718 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -170,7 +170,6 @@ from .searcheffort import SearchEffort from .searchmetadata import SearchMetadata, SearchMetadataTypedDict from .searchop import SEARCH_OP_SERVERS, SearchRequest, SearchRequestTypedDict - from .searchpostop import SEARCH_POST_OP_SERVERS from .searchrequestbody import SearchRequestBody, SearchRequestBodyTypedDict from .searchresponse import ( Results, @@ -329,7 +328,6 @@ "ResultsTypedDict", "Role", "SEARCH_OP_SERVERS", - "SEARCH_POST_OP_SERVERS", "SafeSearch", "SearchEffort", "SearchMetadata", @@ -511,7 +509,6 @@ "SEARCH_OP_SERVERS": ".searchop", "SearchRequest": ".searchop", "SearchRequestTypedDict": ".searchop", - "SEARCH_POST_OP_SERVERS": ".searchpostop", "SearchRequestBody": ".searchrequestbody", "SearchRequestBodyTypedDict": ".searchrequestbody", "Results": ".searchresponse", diff --git a/src/youdotcom/models/answercitation.py b/src/youdotcom/models/answercitation.py index e781c8a..2cee1cd 100644 --- a/src/youdotcom/models/answercitation.py +++ b/src/youdotcom/models/answercitation.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import List +from typing import List, Optional from youdotcom.types import BaseModel @@ -9,5 +9,5 @@ class AnswerCitation(BaseModel): source: str r"""The URL of the cited source.""" - excerpts: List[str] = [] + excerpts: Optional[List[str]] = None r"""Verbatim excerpts from the cited source that support the answer.""" diff --git a/src/youdotcom/models/answerresponse.py b/src/youdotcom/models/answerresponse.py index 9c1498b..2b67887 100644 --- a/src/youdotcom/models/answerresponse.py +++ b/src/youdotcom/models/answerresponse.py @@ -8,7 +8,7 @@ class AnswerResults(BaseModel): r"""Search results grouped by result type.""" - web: List[AnswerSearchResult] = [] + web: Optional[List[AnswerSearchResult]] = None r"""All web search results considered during answer synthesis.""" @@ -18,8 +18,8 @@ class AnswerResponse(BaseModel): answer: str r"""The synthesized response with numbered inline citations that reference items in the ``citations`` array.""" - citations: List[AnswerCitation] = [] + citations: Optional[List[AnswerCitation]] = None r"""The sources cited in the answer, in citation order.""" - results: AnswerResults = AnswerResults() + results: Optional[AnswerResults] = None r"""Search results grouped by result type.""" diff --git a/src/youdotcom/models/answersearchresult.py b/src/youdotcom/models/answersearchresult.py index adc5d47..3286c63 100644 --- a/src/youdotcom/models/answersearchresult.py +++ b/src/youdotcom/models/answersearchresult.py @@ -12,7 +12,7 @@ class AnswerSearchResult(BaseModel): title: str r"""The title of the source webpage.""" - snippets: List[str] = [] + snippets: Optional[List[str]] = None r"""Text snippets from the search result that preview its content.""" page_age: Optional[str] = None diff --git a/src/youdotcom/models/searchpostop.py b/src/youdotcom/models/searchpostop.py deleted file mode 100644 index 5f22c29..0000000 --- a/src/youdotcom/models/searchpostop.py +++ /dev/null @@ -1,8 +0,0 @@ - - -from __future__ import annotations - - -SEARCH_POST_OP_SERVERS = [ - "https://api.you.com", -] diff --git a/tests/mockserver/internal/handler/pathpostv1search.go b/tests/mockserver/internal/handler/pathpostv1search.go index 6afcf48..748973f 100644 --- a/tests/mockserver/internal/handler/pathpostv1search.go +++ b/tests/mockserver/internal/handler/pathpostv1search.go @@ -10,9 +10,6 @@ import ( func pathPostV1Search(dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { - _ = req.Header.Get("x-speakeasy-test-name") - _ = req.Header.Get("x-speakeasy-test-instance-id") - if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { log.Printf("assertion error: %s\n", err) http.Error(w, err.Error(), http.StatusUnauthorized) From a3a5a80f91a9484111a787cefda2007ee581884e Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 20:58:48 -0700 Subject: [PATCH 29/35] =?UTF-8?q?Merge=20search=5Fhelpers=20into=20you.sea?= =?UTF-8?q?rch()=20=E2=80=94=20one=20keyless-capable=20search=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search_helpers module was a standalone function that called POST /v1/agents/search (the keyless-capable proxy). you.search() called POST /v1/search. These were the same operation with different endpoints. Now you.search() targets POST /v1/agents/search directly. With no API key, runs in the free tier (100 queries/day, count <= 50, no livecrawl). With a key, the proxy forwards to the full search endpoint. Changes: - search()/search_async() now POST to /v1/agents/search (was /v1/search) - Added 402 PaymentRequiredResponseError handling to search() - Added .upper() normalization for country/language (accept plain strings) - Changed param types from enums to str (callers don't need enum imports) - language default changed from Language.EN to None (SearchRequestBody defaults to EN) - Deleted src/youdotcom/search_helpers.py (merged into you.search()) - Renamed test_search_helpers.py to test_search_keyless.py - Updated all tests, docs, mock server route, CHANGELOG, MIGRATION 54 unit tests + 117 mock server tests + 45 performance tests pass. mypy clean (104 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 +- MIGRATION.md | 25 +- README.md | 7 +- docs/sdks/you/README.md | 27 +- src/youdotcom/sdk.py | 148 ++++---- src/youdotcom/search_helpers.py | 320 ------------------ tests/PERFORMANCE_TESTING.md | 2 +- tests/README.md | 6 +- .../internal/handler/generated_handlers.go | 2 +- tests/test_direct_methods.py | 4 +- tests/test_live.py | 27 +- tests/test_performance.py | 42 +-- tests/test_search.py | 5 +- ...arch_helpers.py => test_search_keyless.py} | 52 ++- 14 files changed, 180 insertions(+), 497 deletions(-) delete mode 100644 src/youdotcom/search_helpers.py rename tests/{test_search_helpers.py => test_search_keyless.py} (79%) diff --git a/CHANGELOG.md b/CHANGELOG.md index d627442..7296a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,30 +9,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes -- **Sub-SDK Removal**: The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) have been removed. Methods are now direct on the `You` class. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. `you.search.unified()` (GET `/v1/search`) has been removed; use `you.search()` (POST `/v1/search`) instead. The old→new mapping: +- **Sub-SDK Removal**: The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) and the `search_helpers` module have been removed. Methods are now direct on the `You` class. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. `you.search.unified()` (GET `/v1/search`) has been removed; use `you.search()` (POST `/v1/agents/search`) instead. The standalone `search_helpers.search()` function has been merged into `you.search()`. The old→new mapping: | Old (removed) | New | |---------------|-----| | `you.agents.runs.create(request=...)` | `you.agents(request=...)` | | `you.agents.runs.create_async(request=...)` | `you.agents_async(request=...)` | -| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/search`) | +| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/agents/search`) | | `you.search_post(query=...)` | `you.search(query=...)` | | `you.search_post_async(query=...)` | `you.search_async(query=...)` | | `you.contents.generate(urls=...)` | `you.contents(urls=...)` | | `you.contents.generate_async(urls=...)` | `you.contents_async(urls=...)` | +| `search_helpers.search(client, query=...)` | `you.search(query=...)` | +| `search_helpers.search_async(client, query=...)` | `you.search_async(query=...)` | Accessing `you.agents`, `you.search`, or `you.contents` as attributes will now fail at runtime — update to the direct method calls above. ### Added - **Answer API**: New direct method `you.answer()` / `you.answer_async()` for `POST /v1/answer`. Returns a synthesized markdown answer with inline citations (`[[1, 2]]`), a citations array (source URLs + supporting excerpts), and web results. Accepts `query` (required), `freshness`, `country`, `language`, `include_domains`, `exclude_domains`, `boost_domains`. Requires an API key. Country and language accept plain strings (e.g. `"us"`, `"en"`) and are normalized to uppercase automatically. -- **Keyless search helper**: `search_helpers.search()` / `search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. With no API key, runs in the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, forwards to the full search endpoint. Language strings are normalized to uppercase. +- **Keyless search**: `you.search()` / `you.search_async()` now target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. With no API key, runs in the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, the proxy forwards to the full search endpoint. Country and language strings are normalized to uppercase. The standalone `search_helpers` module has been removed; its functionality is now a direct method on `You`. - **`PaymentRequiredResponseError`**: New first-class error class for HTTP 402 responses, matching the `UpgradeRequiredResponse` schema (`error`, `message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). Shared by both search and answer 402 handlers. Replaces the previous `FreeTierLimitError`. ### Changed - **Direct methods on `You`**: The sub-SDK access patterns (`you.agents.runs.create()`, `you.search.unified()`, `you.contents.generate()`) and the direct aliases (`you.create_run()`, `you.search_unified()`, `you.generate_contents()`, `you.search_post()`) from the prior deprecation have all been replaced with the final direct method names: `you.agents()`, `you.search()`, `you.contents()` (plus async variants). See the Breaking Changes table above. -- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. The `search_helpers` module targets the keyless-capable proxy at `api.you.com/v1/agents/search`; the `you.search()` method targets `/v1/search` on the same host. +- **Search/Contents host**: `SEARCH_OP_SERVERS`, `SEARCH_POST_OP_SERVERS`, and `CONTENTS_OP_SERVERS` changed from `https://ydc-index.io` to `https://api.you.com` to align with the MCP server and published docs. `you.search()` targets the keyless-capable proxy at `api.you.com/v1/agents/search`. - **422 error data model**: `UnprocessableEntityResponseErrorData` now includes optional `detail` (FastAPI validation array) and `errors` (JSON:API array) fields in addition to the existing `error` field. All three 422 response shapes deserialize without crashing. Backward compatible — existing code accessing `.error` still works. - **500 error data model**: `InternalServerErrorResponseData` now includes an optional `errors` field for JSON:API format 500 responses. Backward compatible. - **No longer generated by Speakeasy**: Removed all "Code generated by Speakeasy — DO NOT EDIT" disclaimers and the Speakeasy badge from the README. The SDK is now hand-maintained. diff --git a/MIGRATION.md b/MIGRATION.md index d888a99..3a13fa0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -23,7 +23,17 @@ Requires an API key. `country` and `language` accept plain strings (e.g. `"us"`, ### Keyless Search -`search_helpers.search()` / `search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). +`you.search()` / `you.search_async()` target `POST /v1/agents/search` on `api.you.com` — the keyless-capable proxy. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, the proxy forwards to the full search endpoint. + +The standalone `search_helpers` module has been removed. Its `search()` / `search_async()` functions are now direct methods on `You`: + +```python +# Before (2.5.x): from youdotcom.search_helpers import search +# search(you, query="...") + +# After (2.6.0): +you.search(query="...") +``` ### Host Change: ydc-index.io → api.you.com @@ -31,16 +41,15 @@ Requires an API key. `country` and `language` accept plain strings (e.g. `"us"`, ### FreeTierLimitError → PaymentRequiredResponseError -The standalone `FreeTierLimitError` exception in `search_helpers.py` has been replaced with the first-class `PaymentRequiredResponseError` (extends `YouError`). The new error provides structured data: +The standalone `FreeTierLimitError` exception has been replaced with the first-class `PaymentRequiredResponseError` (extends `YouError`). The new error provides structured data: ```python from youdotcom import You from youdotcom.errors import PaymentRequiredResponseError -from youdotcom.search_helpers import search with You() as you: try: - search(you, query="test", count=100) # exceeds free tier + you.search(query="test", count=100) # exceeds free tier except PaymentRequiredResponseError as e: print(e.data.message) # "Insufficient credits" print(e.data.upgrade_url) # "https://you.com/platform" @@ -58,19 +67,21 @@ The SDK is now hand-maintained. All "Code generated by Speakeasy — DO NOT EDIT ### Sub-SDK Removal (Breaking) -The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) have been removed. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. Methods are now direct on the `You` class: +The sub-SDKs (`Agents`, `Search`, `ContentsSDK`) and the `search_helpers` module have been removed. The `you.agents`, `you.search`, and `you.contents` attributes no longer resolve to sub-SDK objects — they are now method calls. Methods are now direct on the `You` class: | Old (removed) | New | |---------------|-----| | `you.agents.runs.create(request=...)` | `you.agents(request=...)` | | `you.agents.runs.create_async(request=...)` | `you.agents_async(request=...)` | -| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/search`) | +| `you.search.unified(query=...)` | `you.search(query=...)` (POST `/v1/agents/search`) | | `you.search_post(query=...)` | `you.search(query=...)` | | `you.search_post_async(query=...)` | `you.search_async(query=...)` | | `you.contents.generate(urls=...)` | `you.contents(urls=...)` | | `you.contents.generate_async(urls=...)` | `you.contents_async(urls=...)` | +| `search_helpers.search(client, query=...)` | `you.search(query=...)` | +| `search_helpers.search_async(client, query=...)` | `you.search_async(query=...)` | -`GET /v1/search` (`you.search.unified()`) has been removed. Use `POST /v1/search` (`you.search()`) instead. Accessing `you.agents`, `you.search`, or `you.contents` as sub-SDK attributes will now fail at runtime — update to the direct method calls above. +`GET /v1/search` (`you.search.unified()`) has been removed. Use `POST /v1/agents/search` (`you.search()`) instead. Accessing `you.agents`, `you.search`, or `you.contents` as sub-SDK attributes will now fail at runtime — update to the direct method calls above. Importing from `youdotcom.search_helpers` will also fail — use `you.search()` directly. ## 2.4.0 → 2.5.0 diff --git a/README.md b/README.md index 5a7479a..1291f83 100644 --- a/README.md +++ b/README.md @@ -239,19 +239,18 @@ with You( ### Keyless Search -The SDK supports keyless search via the `/v1/agents/search` proxy endpoint. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). +`you.search()` targets `POST /v1/agents/search` — the keyless-capable proxy. No API key required for the free tier (100 queries/day, count ≤ 50, no livecrawl). ```python from youdotcom import You -from youdotcom.search_helpers import search # No API key — uses the free tier you = You() -res = search(you, query="What is the capital of France?", count=5) +res = you.search(query="What is the capital of France?", count=5) print(res.results.web[0].title) ``` -With an API key, the same helper forwards to the full `/v1/search` endpoint with no restrictions. A `402` response raises `PaymentRequiredResponseError` with structured data (`message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). +With an API key, the same method forwards to the full search endpoint with no restrictions. A `402` response raises `PaymentRequiredResponseError` with structured data (`message`, `upgrade_url`, `limit`, `used`, `period`, `reset_at`). diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 2edb793..619d8ae 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -65,13 +65,22 @@ with You( ## search -This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. +Search via `POST /v1/agents/search` — the keyless-capable proxy. With no API key configured, runs in the free tier (100 queries/day, count ≤ 50, no livecrawl). With a key, the proxy forwards to the full search endpoint. Country and language accept plain strings and are normalized to uppercase. -`POST` is the recommended method when using complex parameters such as `include_domains` or `exclude_domains`. These fields accept JSON arrays in the request body, which is unambiguous and supports up to 500 domains per request—something that would exceed URL length limits with GET. Use GET for simple queries where HTTP cacheability matters. +### Example Usage: keyless + +```python +from youdotcom import You + +# No API key — uses the free tier +you = You() +res = you.search(query="What is the capital of France?", count=5) +print(res.results.web[0].title) +``` ### Example Usage: authFailure - + ```python import os from youdotcom import You, models @@ -95,7 +104,7 @@ with You( ``` ### Example Usage: authorizationFailure - + ```python import os from youdotcom import You, models @@ -119,7 +128,7 @@ with You( ``` ### Example Usage: invalidOrExpired - + ```python import os from youdotcom import You, models @@ -143,7 +152,7 @@ with You( ``` ### Example Usage: invalidParams - + ```python import os from youdotcom import You, models @@ -167,7 +176,7 @@ with You( ``` ### Example Usage: missingApiKey - + ```python import os from youdotcom import You, models @@ -191,7 +200,7 @@ with You( ``` ### Example Usage: missingScopes - + ```python import os from youdotcom import You, models @@ -215,7 +224,7 @@ with You( ``` ### Example Usage: otherAuthParsing - + ```python import os from youdotcom import You, models diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index a4e6516..f6ce3a7 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -966,15 +966,13 @@ def search( *, query: str, count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, + freshness: Optional[str] = None, offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, + country: Optional[str] = None, + language: Optional[str] = None, + safesearch: Optional[str] = None, + livecrawl: Optional[str] = None, + livecrawl_formats: Optional[Iterable[str]] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -984,31 +982,33 @@ def search( timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources - - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `POST` is the recommended method when using complex parameters such as `include_domains` or `exclude_domains`. These fields accept JSON arrays in the request body, which is unambiguous and supports up to 500 domains per request—something that would exceed URL length limits with GET. Use GET for simple queries where HTTP cacheability matters. - - :param query: The search query used to retrieve relevant results from the web. You can also include [search operators](https://docs.you.com/search/search-operators) to refine your search. - :param count: Specifies the maximum number of search results to return per section (the sections are `web` and `news`. See the JSON response to visualize them). - :param freshness: Specifies the freshness of the results to return. Provide either one of `day`, `week`, `month`, `year`, or a date range string in the format `YYYY-MM-DDtoYYYY-MM-DD`. - - When your search query includes a temporal keyword and you also set a freshness parameter, the search will use the broader (i.e., less restrictive) of the two timeframes. For example, if you use `query=news+this+week&freshness=month`, the results will use a freshness of month. - :param offset: Indicates the `offset` for pagination. The `offset` is calculated in multiples of `count`. For example, if `count = 5` and `offset = 1`, results 5–10 will be returned. Range `0 ≤ offset ≤ 9`. - :param country: The country code that determines the geographical focus of the web results. - :param language: The language of the web results that will be returned (BCP 47 format). - :param safesearch: Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. - :param livecrawl: Indicates which section(s) of search results to livecrawl and return full page content. - :param livecrawl_formats: Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `[\"html\", \"markdown\"]`. - :param include_domains: A list of domains to restrict search results to. Only results from these domains will be returned. Supports up to 500 domains. This is a strict allowlist, not a boost — results are limited exclusively to the specified domains. - - Cannot be combined with `exclude_domains`; passing both will return a `422` error. - :param exclude_domains: A list of domains to exclude from search results. Results from these domains will be filtered out. Supports up to 500 domains. - - Cannot be combined with `include_domains`; passing both will return a `422` error. - :param boost_domains: A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - :param crawl_timeout: Maximum time in seconds to wait for page content when `livecrawl` is enabled. Must be between 1 and 60 seconds. Default is 10 seconds. + r"""Search via ``POST /v1/agents/search`` (keyless-capable). + + With no API key configured, runs in the free tier + (100 queries/day, count <= 50, no livecrawl). + With a key, the proxy forwards to the full search endpoint. + A ``402`` response raises + :class:`~youdotcom.errors.PaymentRequiredResponseError`. + + Enum-typed parameters (``country``, ``safesearch``, ``livecrawl``, + ``freshness``) accept plain strings -- pydantic coerces them when + building the request body, so callers don't need to import enum classes. + + :param query: The search query used to retrieve relevant results from the web. + :param count: Max results per section (1-50 on the free tier). + :param freshness: ``"day"``, ``"week"``, ``"month"``, ``"year"``, or + ``"YYYY-MM-DDtoYYYY-MM-DD"``. + :param offset: Pagination offset (multiples of ``count``). + :param country: Country code for geographical focus. + :param language: BCP 47 language code (default ``"en"``). + :param safesearch: ``"strict"``, ``"moderate"``, or ``"off"``. + :param livecrawl: ``"web"``, ``"news"``, or ``"all"`` (not allowed on + the free tier). + :param livecrawl_formats: ``["html"]``, ``["markdown"]``, or both. + :param include_domains: Restrict results to these domains (<= 500). + :param exclude_domains: Exclude these domains (<= 500). + :param boost_domains: Boost these domains in ranking (<= 500). + :param crawl_timeout: Max seconds to wait for livecrawl (1-60, default 10). :param retries: Override the default retry configuration for this method :param server_url: Override the default server URL for this method :param timeout_ms: Override the default request timeout configuration for this method in milliseconds @@ -1024,13 +1024,12 @@ def search( else: base_url = self._get_url(None, None) - request = models.SearchRequestBody( + body: dict[str, Any] = dict( query=query, count=count, freshness=freshness, offset=offset, - country=country, - language=language, + country=country.upper() if isinstance(country, str) else country, safesearch=safesearch, livecrawl=livecrawl, livecrawl_formats=utils.unmarshal( @@ -1041,10 +1040,13 @@ def search( boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), crawl_timeout=crawl_timeout, ) + if language is not None: + body["language"] = language.upper() if isinstance(language, str) else language + request = models.SearchRequestBody(**body) req = self._build_request( method="POST", - path="/v1/search", + path="/v1/agents/search", base_url=base_url, url_variables=url_variables, request=request, @@ -1074,12 +1076,12 @@ def search( hook_ctx=HookContext( config=self.sdk_configuration, base_url=base_url or "", - operation_id="searchPost", + operation_id="agentsSearch", oauth2_scopes=None, security_source=get_security_from_env( self.sdk_configuration.security, models.Security ), - tags=None, + tags=["search"], extensions=None, ), request=req, @@ -1090,6 +1092,11 @@ def search( response_data: Any = None if utils.match_response(http_res, "200", "application/json"): return unmarshal_json_response(models.SearchResponse, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.UnauthorizedResponseErrorData, http_res @@ -1124,15 +1131,13 @@ async def search_async( *, query: str, count: Optional[int] = 10, - freshness: Optional[ - Union[models.FreshnessValue, models.FreshnessValueTypedDict] - ] = None, + freshness: Optional[str] = None, offset: Optional[int] = None, - country: Optional[models.Country] = None, - language: Optional[models.Language] = models.Language.EN, - safesearch: Optional[models.SafeSearch] = None, - livecrawl: Optional[models.LiveCrawl] = None, - livecrawl_formats: Optional[Iterable[models.LiveCrawlFormats]] = None, + country: Optional[str] = None, + language: Optional[str] = None, + safesearch: Optional[str] = None, + livecrawl: Optional[str] = None, + livecrawl_formats: Optional[Iterable[str]] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -1142,35 +1147,9 @@ async def search_async( timeout_ms: Optional[int] = None, http_headers: Optional[Mapping[str, str]] = None, ) -> models.SearchResponse: - r"""Returns a list of unified search results from web and news sources - - This endpoint is designed to return LLM-ready web results based on a user's query. Based on a classification mechanism, it can return web results and news associated with your query. If you need to feed an LLM with the results of a query that sounds like `What are the latest geopolitical updates from India`, then this endpoint is the right one for you. - - `POST` is the recommended method when using complex parameters such as `include_domains` or `exclude_domains`. These fields accept JSON arrays in the request body, which is unambiguous and supports up to 500 domains per request—something that would exceed URL length limits with GET. Use GET for simple queries where HTTP cacheability matters. - - :param query: The search query used to retrieve relevant results from the web. You can also include [search operators](https://docs.you.com/search/search-operators) to refine your search. - :param count: Specifies the maximum number of search results to return per section (the sections are `web` and `news`. See the JSON response to visualize them). - :param freshness: Specifies the freshness of the results to return. Provide either one of `day`, `week`, `month`, `year`, or a date range string in the format `YYYY-MM-DDtoYYYY-MM-DD`. + r"""Search via ``POST /v1/agents/search`` (keyless-capable). - When your search query includes a temporal keyword and you also set a freshness parameter, the search will use the broader (i.e., less restrictive) of the two timeframes. For example, if you use `query=news+this+week&freshness=month`, the results will use a freshness of month. - :param offset: Indicates the `offset` for pagination. The `offset` is calculated in multiples of `count`. For example, if `count = 5` and `offset = 1`, results 5–10 will be returned. Range `0 ≤ offset ≤ 9`. - :param country: The country code that determines the geographical focus of the web results. - :param language: The language of the web results that will be returned (BCP 47 format). - :param safesearch: Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. - :param livecrawl: Indicates which section(s) of search results to livecrawl and return full page content. - :param livecrawl_formats: Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `[\"html\", \"markdown\"]`. - :param include_domains: A list of domains to restrict search results to. Only results from these domains will be returned. Supports up to 500 domains. This is a strict allowlist, not a boost — results are limited exclusively to the specified domains. - - Cannot be combined with `exclude_domains`; passing both will return a `422` error. - :param exclude_domains: A list of domains to exclude from search results. Results from these domains will be filtered out. Supports up to 500 domains. - - Cannot be combined with `include_domains`; passing both will return a `422` error. - :param boost_domains: A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`). - :param crawl_timeout: Maximum time in seconds to wait for page content when `livecrawl` is enabled. Must be between 1 and 60 seconds. Default is 10 seconds. - :param retries: Override the default retry configuration for this method - :param server_url: Override the default server URL for this method - :param timeout_ms: Override the default request timeout configuration for this method in milliseconds - :param http_headers: Additional headers to set or replace on requests. + Async variant of :meth:`search`. """ base_url = None url_variables = None @@ -1182,13 +1161,12 @@ async def search_async( else: base_url = self._get_url(None, None) - request = models.SearchRequestBody( + body: dict[str, Any] = dict( query=query, count=count, freshness=freshness, offset=offset, - country=country, - language=language, + country=country.upper() if isinstance(country, str) else country, safesearch=safesearch, livecrawl=livecrawl, livecrawl_formats=utils.unmarshal( @@ -1199,10 +1177,13 @@ async def search_async( boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), crawl_timeout=crawl_timeout, ) + if language is not None: + body["language"] = language.upper() if isinstance(language, str) else language + request = models.SearchRequestBody(**body) req = self._build_request_async( method="POST", - path="/v1/search", + path="/v1/agents/search", base_url=base_url, url_variables=url_variables, request=request, @@ -1232,12 +1213,12 @@ async def search_async( hook_ctx=HookContext( config=self.sdk_configuration, base_url=base_url or "", - operation_id="searchPost", + operation_id="agentsSearch", oauth2_scopes=None, security_source=get_security_from_env( self.sdk_configuration.security, models.Security ), - tags=None, + tags=["search"], extensions=None, ), request=req, @@ -1248,6 +1229,11 @@ async def search_async( response_data: Any = None if utils.match_response(http_res, "200", "application/json"): return unmarshal_json_response(models.SearchResponse, http_res) + if utils.match_response(http_res, "402", "application/json"): + response_data = unmarshal_json_response( + errors.PaymentRequiredResponseErrorData, http_res + ) + raise errors.PaymentRequiredResponseError(response_data, http_res) if utils.match_response(http_res, "401", "application/json"): response_data = unmarshal_json_response( errors.UnauthorizedResponseErrorData, http_res diff --git a/src/youdotcom/search_helpers.py b/src/youdotcom/search_helpers.py deleted file mode 100644 index c34c6d9..0000000 --- a/src/youdotcom/search_helpers.py +++ /dev/null @@ -1,320 +0,0 @@ -"""Hand-maintained search helpers targeting ``/v1/agents/search``. - -This module is hand-maintained (the SDK is no longer generated by Speakeasy). -It mirrors the ``you.search()`` request machinery but POSTs to -``/v1/agents/search`` instead of ``/v1/search``. The agents-search endpoint is -a proxy that: - -- **With an API key** → forwards to ``/v1/search`` unrestricted (full features). -- **Without a key** → free tier: IP-rate-limited, ``count`` capped at 1–50, - ``livecrawl`` not allowed. Returns ``402`` on any limit. - -Use this as the default search entrypoint for skills, plugins, and MCP tools. -The ``you.search()`` method remains available for callers -who need the raw ``/v1/search`` endpoint. -""" - -from __future__ import annotations - -from typing import Any, Iterable, List, Mapping, Optional - -from youdotcom import errors, models, utils -from youdotcom._hooks import HookContext -from youdotcom.sdk import You -from youdotcom.types import OptionalNullable, UNSET -from youdotcom.utils import get_security_from_env -from youdotcom.utils.unmarshal_json_response import unmarshal_json_response - - -def search( - client: You, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[str] = None, - offset: Optional[int] = None, - country: Optional[str] = None, - language: Optional[str] = None, - safesearch: Optional[str] = None, - livecrawl: Optional[str] = None, - livecrawl_formats: Optional[Iterable[str]] = None, - include_domains: Optional[Iterable[str]] = None, - exclude_domains: Optional[Iterable[str]] = None, - boost_domains: Optional[Iterable[str]] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, -) -> models.SearchResponse: - r"""Search via ``POST /v1/agents/search`` (keyless-capable default). - - With no API key configured on ``client``, runs in the free tier - (count ≤ 50, no livecrawl). A ``402`` response raises - :class:`~youdotcom.errors.PaymentRequiredResponseError` carrying the upgrade message. - - Enum-typed parameters (``country``, ``safesearch``, ``livecrawl``, - ``freshness``) accept plain strings — pydantic coerces them when building - the request body, so callers don't need to import enum classes. - - :param client: A ``You`` SDK client (keyed or keyless). - :param query: The search query. - :param count: Max results per section (1–50 on the free tier). - :param freshness: ``"day"``, ``"week"``, ``"month"``, ``"year"``, or - ``"YYYY-MM-DDtoYYYY-MM-DD"``. - :param offset: Pagination offset (multiples of ``count``). - :param country: Country code for geographical focus. - :param language: BCP 47 language code (default ``"en"``). - :param safesearch: ``"strict"``, ``"moderate"``, or ``"off"``. - :param livecrawl: ``"web"``, ``"news"``, or ``"all"`` (not allowed on - the free tier). - :param livecrawl_formats: ``["html"]``, ``["markdown"]``, or both. - :param include_domains: Restrict results to these domains (≤ 500). - :param exclude_domains: Exclude these domains (≤ 500). - :param boost_domains: Boost these domains in ranking (≤ 500). - :param crawl_timeout: Max seconds to wait for livecrawl (1–60, default 10). - :param retries: Override the client's retry configuration. - :param server_url: Override the default server URL. - :param timeout_ms: Override the request timeout in milliseconds. - :param http_headers: Additional headers to set or replace. - """ - base_url = server_url if server_url is not None else client._get_url(None, None) - if timeout_ms is None: - timeout_ms = client.sdk_configuration.timeout_ms - - body: dict[str, Any] = dict( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country.upper() if isinstance(country, str) else country, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=utils.unmarshal( - livecrawl_formats, Optional[List[models.LiveCrawlFormats]] - ), - include_domains=utils.unmarshal(include_domains, Optional[List[str]]), - exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), - boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), - crawl_timeout=crawl_timeout, - ) - if language is not None: - body["language"] = language.upper() if isinstance(language, str) else language - request = models.SearchRequestBody(**body) - - req = client._build_request( - method="POST", - path="/v1/agents/search", - base_url=base_url, - url_variables=None, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=client.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.SearchRequestBody - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if client.sdk_configuration.retry_config is not UNSET: - retries = client.sdk_configuration.retry_config - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = client.do_request( - hook_ctx=HookContext( - config=client.sdk_configuration, - base_url=base_url or "", - operation_id="agentsSearch", - oauth2_scopes=None, - security_source=get_security_from_env( - client.sdk_configuration.security, models.Security - ), - tags=["search"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - return _handle_response(http_res) - - -async def search_async( - client: You, - *, - query: str, - count: Optional[int] = 10, - freshness: Optional[str] = None, - offset: Optional[int] = None, - country: Optional[str] = None, - language: Optional[str] = None, - safesearch: Optional[str] = None, - livecrawl: Optional[str] = None, - livecrawl_formats: Optional[Iterable[str]] = None, - include_domains: Optional[Iterable[str]] = None, - exclude_domains: Optional[Iterable[str]] = None, - boost_domains: Optional[Iterable[str]] = None, - crawl_timeout: Optional[int] = 10, - retries: OptionalNullable[utils.RetryConfig] = UNSET, - server_url: Optional[str] = None, - timeout_ms: Optional[int] = None, - http_headers: Optional[Mapping[str, str]] = None, -) -> models.SearchResponse: - """Async variant of :func:`search`.""" - base_url = server_url if server_url is not None else client._get_url(None, None) - if timeout_ms is None: - timeout_ms = client.sdk_configuration.timeout_ms - - body: dict[str, Any] = dict( - query=query, - count=count, - freshness=freshness, - offset=offset, - country=country.upper() if isinstance(country, str) else country, - safesearch=safesearch, - livecrawl=livecrawl, - livecrawl_formats=utils.unmarshal( - livecrawl_formats, Optional[List[models.LiveCrawlFormats]] - ), - include_domains=utils.unmarshal(include_domains, Optional[List[str]]), - exclude_domains=utils.unmarshal(exclude_domains, Optional[List[str]]), - boost_domains=utils.unmarshal(boost_domains, Optional[List[str]]), - crawl_timeout=crawl_timeout, - ) - if language is not None: - body["language"] = language.upper() if isinstance(language, str) else language - request = models.SearchRequestBody(**body) - - req = client._build_request_async( - method="POST", - path="/v1/agents/search", - base_url=base_url, - url_variables=None, - request=request, - request_body_required=True, - request_has_path_params=False, - request_has_query_params=True, - user_agent_header="user-agent", - accept_header_value="application/json", - http_headers=http_headers, - security=client.sdk_configuration.security, - get_serialized_body=lambda: utils.serialize_request_body( - request, False, False, "json", models.SearchRequestBody - ), - allow_empty_value=None, - timeout_ms=timeout_ms, - ) - - if retries == UNSET: - if client.sdk_configuration.retry_config is not UNSET: - retries = client.sdk_configuration.retry_config - retry_config = None - if isinstance(retries, utils.RetryConfig): - retry_config = (retries, ["429", "500", "502", "503", "504"]) - - http_res = await client.do_request_async( - hook_ctx=HookContext( - config=client.sdk_configuration, - base_url=base_url or "", - operation_id="agentsSearch", - oauth2_scopes=None, - security_source=get_security_from_env( - client.sdk_configuration.security, models.Security - ), - tags=["search"], - extensions=None, - ), - request=req, - is_error_status_code=lambda c: utils.match_status_codes(["4XX", "5XX"], c), - retry_config=retry_config, - ) - - return await _handle_response_async(http_res) - - -def _handle_response(http_res: Any) -> models.SearchResponse: - """Branch on ``http_res`` status → return ``SearchResponse`` or raise.""" - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "402", "application/json"): - response_data = unmarshal_json_response( - errors.PaymentRequiredResponseErrorData, http_res - ) - raise errors.PaymentRequiredResponseError(response_data, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, text) - if utils.match_response(http_res, "5XX", "*"): - text = utils.stream_to_text(http_res) - raise errors.YouDefaultError("API error occurred", http_res, text) - raise errors.YouDefaultError("Unexpected response received", http_res) - - -async def _handle_response_async(http_res: Any) -> models.SearchResponse: - """Async variant of :func:`_handle_response`.""" - response_data: Any = None - if utils.match_response(http_res, "200", "application/json"): - return unmarshal_json_response(models.SearchResponse, http_res) - if utils.match_response(http_res, "402", "application/json"): - response_data = unmarshal_json_response( - errors.PaymentRequiredResponseErrorData, http_res - ) - raise errors.PaymentRequiredResponseError(response_data, http_res) - if utils.match_response(http_res, "401", "application/json"): - response_data = unmarshal_json_response( - errors.UnauthorizedResponseErrorData, http_res - ) - raise errors.UnauthorizedResponseError(response_data, http_res) - if utils.match_response(http_res, "403", "application/json"): - response_data = unmarshal_json_response( - errors.ForbiddenResponseErrorData, http_res - ) - raise errors.ForbiddenResponseError(response_data, http_res) - if utils.match_response(http_res, "422", "application/json"): - response_data = unmarshal_json_response( - errors.UnprocessableEntityResponseErrorData, http_res - ) - raise errors.UnprocessableEntityResponseError(response_data, http_res) - if utils.match_response(http_res, "500", "application/json"): - response_data = unmarshal_json_response( - errors.InternalServerErrorResponseData, http_res - ) - raise errors.InternalServerErrorResponse(response_data, http_res) - if utils.match_response(http_res, "4XX", "*"): - text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, text) - if utils.match_response(http_res, "5XX", "*"): - text = await utils.stream_to_text_async(http_res) - raise errors.YouDefaultError("API error occurred", http_res, text) - raise errors.YouDefaultError("Unexpected response received", http_res) diff --git a/tests/PERFORMANCE_TESTING.md b/tests/PERFORMANCE_TESTING.md index c102d3c..3e2426e 100644 --- a/tests/PERFORMANCE_TESTING.md +++ b/tests/PERFORMANCE_TESTING.md @@ -334,7 +334,7 @@ Example: ```python def test_search_with_new_filter(self, server_url, api_key, iterations, show_detailed): """Test description.""" - client = create_test_http_client("get_/v1/search") + client = create_test_http_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): diff --git a/tests/README.md b/tests/README.md index 9b33771..b659ae9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -52,7 +52,7 @@ pytest tests/ -v ### Test Files - `test_client.py` - Helper utilities for creating test HTTP clients -- `test_search.py` - Tests for the Search API (`/v1/search`) +- `test_search.py` - Tests for the Search API (`/v1/agents/search`) - `test_contents.py` - Tests for the Contents API (`/v1/contents`) - `test_runs.py` - Tests for the Agents/Runs API (`/v1/agents/runs`) - `test_research.py` - Tests for the Research API (`/v1/research`) including background mode, output_schema, and source_control @@ -102,14 +102,14 @@ Tests are organized into logical classes using pytest: ### Running Live Tests -The `test_live.py` file contains tests that run against the real You.com API. Keyed tests are skipped unless an API key is provided. Keyless search tests (`TestLiveSearchHelpers`) run without an API key by default, since they verify the free-tier `/v1/agents/search` proxy: +The `test_live.py` file contains tests that run against the real You.com API. Keyed tests are skipped unless an API key is provided. Keyless search tests (`TestLiveSearchKeyless`) run without an API key by default, since they verify the free-tier `/v1/agents/search` proxy: ```bash # Run live tests with your API key (enables keyed tests) YDC_API_KEY="your-api-key" pytest tests/test_live.py -v # Run only keyless live tests (no API key needed) -pytest tests/test_live.py::TestLiveSearchHelpers -v +pytest tests/test_live.py::TestLiveSearchKeyless -v # Run all tests except live tests pytest tests/ --ignore=tests/test_live.py -v diff --git a/tests/mockserver/internal/handler/generated_handlers.go b/tests/mockserver/internal/handler/generated_handlers.go index f53f077..c4e5548 100644 --- a/tests/mockserver/internal/handler/generated_handlers.go +++ b/tests/mockserver/internal/handler/generated_handlers.go @@ -13,7 +13,7 @@ import ( func GeneratedHandlers(ctx context.Context, dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) []*GeneratedHandler { return []*GeneratedHandler{ NewGeneratedHandler(ctx, http.MethodGet, "/v1/search", pathGetV1Search(dir, rt)), - NewGeneratedHandler(ctx, http.MethodPost, "/v1/search", pathPostV1Search(dir, rt)), + NewGeneratedHandler(ctx, http.MethodPost, "/v1/agents/search", pathPostV1Search(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/agents/runs", pathPostV1AgentsRuns(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/contents", pathPostV1Contents(dir, rt)), NewGeneratedHandler(ctx, http.MethodPost, "/v1/research", pathPostV1Research(dir, rt)), diff --git a/tests/test_direct_methods.py b/tests/test_direct_methods.py index edb2356..093fad9 100644 --- a/tests/test_direct_methods.py +++ b/tests/test_direct_methods.py @@ -55,7 +55,7 @@ def _async_you(handler, *, api_key: str | None = "test-key"): # --------------------------------------------------------------------------- -# you.search() — POST /v1/search +# you.search() — POST /v1/agents/search (keyless-capable) # --------------------------------------------------------------------------- @@ -81,7 +81,7 @@ def handler(request): _sync_you(handler).search(query="test") assert captured["method"] == "POST" - assert "/v1/search" in captured["url"] + assert "/v1/agents/search" in captured["url"] def test_passes_params_in_body(self): captured: dict = {} diff --git a/tests/test_live.py b/tests/test_live.py index 4697056..0936c94 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -40,7 +40,7 @@ FinanceResearchEffort, AnswerResponse, ) -from youdotcom.search_helpers import search as search_helper, search_async as search_helper_async + from youdotcom.research_helpers import ( research_background, poll_research_task, @@ -780,38 +780,37 @@ async def test_async_answer(self, you_client): # --------------------------------------------------------------------------- # Search helpers (keyless-capable /v1/agents/search) # --------------------------------------------------------------------------- -class TestLiveSearchHelpers: - """Live tests for search_helpers.search() → POST /v1/agents/search. +class TestLiveSearchKeyless: + """Live tests for you.search() → POST /v1/agents/search (keyless-capable). - These verify the keyless-capable search helper against the real API. + These verify the keyless-capable search method against the real API. With an API key, the proxy forwards to /v1/search with full features. """ - def test_keyed_search_helper(self, you_client): + def test_keyed_search(self, you_client): """search() with an API key returns full SearchResponse.""" with you_client as you: - res = search_helper(you, query="Python programming language", count=5) + res = you.search(query="Python programming language", count=5) assert res.results is not None assert res.results.web is not None assert len(res.results.web) > 0 - def test_keyless_search_helper(self): + def test_keyless_search(self): """search() with NO API key works via the free-tier proxy.""" you = You(timeout_ms=LIVE_TIMEOUT_MS) with you: - res = search_helper(you, query="Python programming language", count=5) + res = you.search(query="Python programming language", count=5) assert res.results is not None assert res.results.web is not None assert len(res.results.web) > 0 - def test_keyless_search_helper_with_filters(self): + def test_keyless_search_with_filters(self): """Keyless search accepts country/freshness/safesearch (server enforces limits).""" you = You(timeout_ms=LIVE_TIMEOUT_MS) with you: - res = search_helper( - you, + res = you.search( query="artificial intelligence news", count=3, country="US", @@ -823,11 +822,11 @@ def test_keyless_search_helper_with_filters(self): assert res.results.web is not None @pytest.mark.asyncio - async def test_async_keyless_search_helper(self): + async def test_async_keyless_search(self): """search_async() with NO API key works via the free-tier proxy.""" you = You(timeout_ms=LIVE_TIMEOUT_MS) async with you: - res = await search_helper_async(you, query="What is machine learning?", count=3) + res = await you.search_async(query="What is machine learning?", count=3) assert res.results is not None assert res.results.web is not None @@ -838,7 +837,7 @@ def test_custom_user_agent_keyless(self): you = You(timeout_ms=LIVE_TIMEOUT_MS) you.sdk_configuration.user_agent = "test-integration/1.0" with you: - res = search_helper(you, query="Python programming language", count=3) + res = you.search(query="Python programming language", count=3) assert res.results is not None assert res.results.web is not None diff --git a/tests/test_performance.py b/tests/test_performance.py index 88ce806..3b7cbd6 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -168,7 +168,7 @@ class TestSearchPerformance: def test_search_basic(self, server_url, api_key, iterations, show_detailed): """Basic search with query only.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -181,7 +181,7 @@ def call(): def test_search_with_count(self, server_url, api_key, iterations, show_detailed): """Search with result count limit.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -194,7 +194,7 @@ def call(): def test_search_with_freshness_day(self, server_url, api_key, iterations, show_detailed): """Search with freshness filter (day).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -207,7 +207,7 @@ def call(): def test_search_with_freshness_week(self, server_url, api_key, iterations, show_detailed): """Search with freshness filter (week).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -220,7 +220,7 @@ def call(): def test_search_with_country_us(self, server_url, api_key, iterations, show_detailed): """Search with country filter (US).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -233,7 +233,7 @@ def call(): def test_search_with_country_gb(self, server_url, api_key, iterations, show_detailed): """Search with country filter (GB).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -246,7 +246,7 @@ def call(): def test_search_with_language_en(self, server_url, api_key, iterations, show_detailed): """Search with language filter (English).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -259,7 +259,7 @@ def call(): def test_search_with_language_es(self, server_url, api_key, iterations, show_detailed): """Search with language filter (Spanish).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -272,7 +272,7 @@ def call(): def test_search_with_safesearch_off(self, server_url, api_key, iterations, show_detailed): """Search with safesearch off.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -285,7 +285,7 @@ def call(): def test_search_with_safesearch_moderate(self, server_url, api_key, iterations, show_detailed): """Search with safesearch moderate.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -298,7 +298,7 @@ def call(): def test_search_with_safesearch_strict(self, server_url, api_key, iterations, show_detailed): """Search with safesearch strict.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -311,7 +311,7 @@ def call(): def test_search_with_pagination(self, server_url, api_key, iterations, show_detailed): """Search with pagination (offset).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -324,7 +324,7 @@ def call(): def test_search_with_livecrawl_web(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl enabled for web results.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -342,7 +342,7 @@ def call(): def test_search_with_livecrawl_news(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl enabled for news results.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -360,7 +360,7 @@ def call(): def test_search_with_livecrawl_all(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl enabled for all results.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -378,7 +378,7 @@ def call(): def test_search_with_livecrawl_html(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl returning HTML format.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -397,7 +397,7 @@ def call(): def test_search_with_livecrawl_markdown(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl returning Markdown format.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -416,7 +416,7 @@ def call(): def test_search_with_all_filters(self, server_url, api_key, iterations, show_detailed): """Search with multiple filters combined.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -438,7 +438,7 @@ def call(): def test_search_with_filters_and_livecrawl(self, server_url, api_key, iterations, show_detailed): """Search with filters and livecrawl combined.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -459,7 +459,7 @@ def call(): def test_search_with_news_livecrawl(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl for news results (news now supports contents).""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): @@ -478,7 +478,7 @@ def call(): def test_search_with_livecrawl_all_news_contents(self, server_url, api_key, iterations, show_detailed): """Search with livecrawl=ALL for both web and news contents.""" - client = create_timing_client("get_/v1/search") + client = create_timing_client("post_/v1/agents/search") with You(server_url=server_url, client=client, api_key_auth=api_key) as you: def call(): diff --git a/tests/test_search.py b/tests/test_search.py index d3b3122..db22ddb 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -12,9 +12,8 @@ # --------------------------------------------------------------------------- -# POST-side error tests: search() must raise the same consolidated -# *ResponseError classes. Uses MockTransport because the mockserver has no -# POST /v1/search handler. +# Error tests: search() (POST /v1/agents/search) must raise the same +# consolidated *ResponseError classes. Uses MockTransport. # --------------------------------------------------------------------------- diff --git a/tests/test_search_helpers.py b/tests/test_search_keyless.py similarity index 79% rename from tests/test_search_helpers.py rename to tests/test_search_keyless.py index 07db204..55aeca4 100644 --- a/tests/test_search_helpers.py +++ b/tests/test_search_keyless.py @@ -1,4 +1,9 @@ -"""Tests for youdotcom.search_helpers — keyless-capable ``/v1/agents/search``.""" +"""Tests for you.search() — keyless-capable ``POST /v1/agents/search``. + +The search method now targets ``/v1/agents/search`` (the keyless-capable +proxy) instead of ``/v1/search``. With no API key it runs in the free tier; +with a key the proxy forwards to the full search endpoint. +""" import json @@ -15,7 +20,6 @@ YouDefaultError, ) from youdotcom.models import SearchResponse -from youdotcom.search_helpers import search, search_async _SEARCH_BODY = json.dumps( @@ -54,7 +58,7 @@ def _async_you(handler, *, api_key: str | None = "test-key"): class TestSearchSuccess: def test_keyed_search_returns_search_response(self): - res = search(_sync_you(_make_handler(200)), query="python", count=5) + res = _sync_you(_make_handler(200)).search(query="python", count=5) assert isinstance(res, SearchResponse) assert res.results is not None assert res.results.web is not None @@ -63,13 +67,12 @@ def test_keyed_search_returns_search_response(self): def test_keyless_search_returns_search_response(self): """No api_key_auth → keyless free-tier path still returns SearchResponse.""" - res = search(_sync_you(_make_handler(200), api_key=None), query="python", count=5) + res = _sync_you(_make_handler(200), api_key=None).search(query="python", count=5) assert isinstance(res, SearchResponse) def test_string_enum_params_accepted(self): """country/safesearch/livecrawl/freshness accept plain strings.""" - res = search( - _sync_you(_make_handler(200)), + res = _sync_you(_make_handler(200)).search( query="python", country="US", safesearch="strict", @@ -80,18 +83,17 @@ def test_string_enum_params_accepted(self): def test_lowercase_language_is_normalized(self): """language='en' should be normalized to 'EN' before model validation.""" - res = search(_sync_you(_make_handler(200)), query="python", language="en") + res = _sync_you(_make_handler(200)).search(query="python", language="en") assert isinstance(res, SearchResponse) def test_lowercase_country_is_normalized(self): """country='us' should be normalized to 'US' before model validation.""" - res = search(_sync_you(_make_handler(200)), query="python", country="us") + res = _sync_you(_make_handler(200)).search(query="python", country="us") assert isinstance(res, SearchResponse) def test_exclude_and_boost_domains_accepted(self): """exclude_domains and boost_domains should be accepted as lists.""" - res = search( - _sync_you(_make_handler(200)), + res = _sync_you(_make_handler(200)).search( query="python", exclude_domains=["spam.com"], boost_domains=["realpython.com"], @@ -109,7 +111,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY ) - search(_sync_you(handler), query="python") + _sync_you(handler).search(query="python") assert captured["method"] == "POST" assert "/v1/agents/search" in captured["url"] @@ -123,7 +125,7 @@ def handler(request): 200, headers={"content-type": "application/json"}, content=_SEARCH_BODY ) - search(_sync_you(handler), query="python") + _sync_you(handler).search(query="python") assert captured["ua"] == f"youdotcom-python-sdk/{__version__}" def test_custom_user_agent_passes_through(self): @@ -142,19 +144,17 @@ def handler(request): client=httpx.Client(transport=httpx.MockTransport(handler)), ) you.sdk_configuration.user_agent = "my-integration/1.0" - search(you, query="python") + you.search(query="python") assert captured["ua"] == "my-integration/1.0" @pytest.mark.asyncio async def test_async_keyed_search_returns_search_response(self): - res = await search_async(_async_you(_make_handler(200)), query="python", count=5) + res = await _async_you(_make_handler(200)).search_async(query="python", count=5) assert isinstance(res, SearchResponse) @pytest.mark.asyncio async def test_async_keyless_search_returns_search_response(self): - res = await search_async( - _async_you(_make_handler(200), api_key=None), query="python", count=5 - ) + res = await _async_you(_make_handler(200), api_key=None).search_async(query="python", count=5) assert isinstance(res, SearchResponse) @@ -166,7 +166,7 @@ def test_402_raises_payment_required_error(self): "upgrade_url": "https://you.com/platform", }) with pytest.raises(PaymentRequiredResponseError) as exc_info: - search(_sync_you(_make_handler(402, body)), query="python", count=100) + _sync_you(_make_handler(402, body)).search(query="python", count=100) assert exc_info.value.status_code == 402 assert exc_info.value.data.message == "Insufficient credits" assert exc_info.value.data.upgrade_url == "https://you.com/platform" @@ -174,22 +174,22 @@ def test_402_raises_payment_required_error(self): def test_401_raises_unauthorized_error(self): body = json.dumps({"detail": "invalid api key"}) with pytest.raises(UnauthorizedResponseError): - search(_sync_you(_make_handler(401, body), api_key="bad-key"), query="python") + _sync_you(_make_handler(401, body), api_key="bad-key").search(query="python") def test_422_raises_unprocessable_entity_error(self): body = json.dumps({"error": "include_domains and exclude_domains are mutually exclusive"}) with pytest.raises(UnprocessableEntityResponseError): - search(_sync_you(_make_handler(422, body)), query="python") + _sync_you(_make_handler(422, body)).search(query="python") def test_500_raises_internal_server_error(self): body = json.dumps({"detail": "internal server error"}) with pytest.raises(InternalServerErrorResponse): - search(_sync_you(_make_handler(500, body)), query="python") + _sync_you(_make_handler(500, body)).search(query="python") def test_4xx_fallback_raises_default_error(self): body = json.dumps({"detail": "rate limited"}) with pytest.raises(YouDefaultError): - search(_sync_you(_make_handler(429, body)), query="python") + _sync_you(_make_handler(429, body)).search(query="python") @pytest.mark.asyncio async def test_async_402_raises_payment_required_error(self): @@ -199,7 +199,7 @@ async def test_async_402_raises_payment_required_error(self): "upgrade_url": "https://you.com/platform", }) with pytest.raises(PaymentRequiredResponseError) as exc_info: - await search_async(_async_you(_make_handler(402, body)), query="python", count=100) + await _async_you(_make_handler(402, body)).search_async(query="python", count=100) assert exc_info.value.status_code == 402 assert exc_info.value.data.error == "payment_required" @@ -207,12 +207,10 @@ async def test_async_402_raises_payment_required_error(self): async def test_async_401_raises_unauthorized_error(self): body = json.dumps({"detail": "unauthorized"}) with pytest.raises(UnauthorizedResponseError): - await search_async( - _async_you(_make_handler(401, body), api_key="bad-key"), query="python" - ) + await _async_you(_make_handler(401, body), api_key="bad-key").search_async(query="python") @pytest.mark.asyncio async def test_async_500_raises_internal_server_error(self): body = json.dumps({"detail": "internal server error"}) with pytest.raises(InternalServerErrorResponse): - await search_async(_async_you(_make_handler(500, body)), query="python") + await _async_you(_make_handler(500, body)).search_async(query="python") From 1286475911611c1e3f19fe148d1a118ca04e6b02 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 21:03:53 -0700 Subject: [PATCH 30/35] Fix stale endpoint ref in deprecated search docs, add 402 to error table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/sdks/search/README.md: POST /v1/search → POST /v1/agents/search - docs/sdks/you/README.md: add PaymentRequiredResponseError (402) to search error table Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- docs/sdks/search/README.md | 2 +- docs/sdks/you/README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sdks/search/README.md b/docs/sdks/search/README.md index ff4d376..a693fcc 100644 --- a/docs/sdks/search/README.md +++ b/docs/sdks/search/README.md @@ -1,6 +1,6 @@ # Search -> **DEPRECATED — removed in the current major version.** The `Search` sub-SDK is no longer available. `GET /v1/search` (`you.search.unified()`) has been removed. Use the direct `POST /v1/search` method on the `You` client instead: +> **DEPRECATED — removed in the current major version.** The `Search` sub-SDK is no longer available. `GET /v1/search` (`you.search.unified()`) has been removed. Use the direct `POST /v1/agents/search` method on the `You` client instead: > > - `you.search(query=...)` (was `you.search.unified(query=...)` and `you.search_post(query=...)`) > - `you.search_async(query=...)` (was `you.search_post_async(query=...)`) diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 619d8ae..ba3304d 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -275,6 +275,7 @@ with You( | Error Type | Status Code | Content Type | | --------------------------------------- | --------------------------------------- | --------------------------------------- | +| errors.PaymentRequiredResponseError | 402 | application/json | | errors.UnauthorizedResponseError | 401 | application/json | | errors.ForbiddenResponseError | 403 | application/json | | errors.UnprocessableEntityResponseError | 422 | application/json | From 39c2f78bfb655f790153220763b29d201de09874 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 21:09:43 -0700 Subject: [PATCH 31/35] Ensure keyless tests across all three layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three test layers now cover the keyless search path (no API key): 1. Unit (MockTransport): TestSearchSuccess + TestSearchErrors in test_search_keyless.py — sync/async keyless with api_key=None, string enum params, .upper() normalization, 402/401/403/422/500 error handling. 2. Mock server (Go): TestSearchKeylessMockServer in test_search_keyless.py — 3 tests hitting the Go mock server with no API key. Mock server handler changed from required to optional X-API-Key header (SecurityHeader false → true) since /v1/agents/search is keyless-capable. 3. Live: TestLiveSearchKeyless in test_live.py — 5 tests against the real API (keyed, keyless, keyless+filters, async keyless, custom UA). Fixed: test_keyed_search now decorated with @requires_api_key so it skips when no key is set instead of silently becoming keyless. All tests pass: 54 unit + 3 mock keyless + 117 mock server + 45 perf + 5 live keyless. mypy clean (104 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../internal/handler/pathpostv1search.go | 2 +- tests/test_live.py | 3 +- tests/test_search_keyless.py | 67 +++++++++++++++++++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/tests/mockserver/internal/handler/pathpostv1search.go b/tests/mockserver/internal/handler/pathpostv1search.go index 748973f..f9cd968 100644 --- a/tests/mockserver/internal/handler/pathpostv1search.go +++ b/tests/mockserver/internal/handler/pathpostv1search.go @@ -10,7 +10,7 @@ import ( func pathPostV1Search(dir *logging.HTTPFileDirectory, rt *tracking.RequestTracker) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { - if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { + if err := assert.SecurityHeader(req, "X-API-Key", true); err != nil { log.Printf("assertion error: %s\n", err) http.Error(w, err.Error(), http.StatusUnauthorized) return diff --git a/tests/test_live.py b/tests/test_live.py index 0936c94..a612683 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -778,7 +778,7 @@ async def test_async_answer(self, you_client): # --------------------------------------------------------------------------- -# Search helpers (keyless-capable /v1/agents/search) +# Keyless search (POST /v1/agents/search — no API key required for free tier) # --------------------------------------------------------------------------- class TestLiveSearchKeyless: """Live tests for you.search() → POST /v1/agents/search (keyless-capable). @@ -787,6 +787,7 @@ class TestLiveSearchKeyless: With an API key, the proxy forwards to /v1/search with full features. """ + @requires_api_key def test_keyed_search(self, you_client): """search() with an API key returns full SearchResponse.""" with you_client as you: diff --git a/tests/test_search_keyless.py b/tests/test_search_keyless.py index 55aeca4..5c8f636 100644 --- a/tests/test_search_keyless.py +++ b/tests/test_search_keyless.py @@ -3,9 +3,16 @@ The search method now targets ``/v1/agents/search`` (the keyless-capable proxy) instead of ``/v1/search``. With no API key it runs in the free tier; with a key the proxy forwards to the full search endpoint. + +Three test layers: +- **Unit** (MockTransport): TestSearchSuccess, TestSearchErrors — fast, no server. +- **Mock server** (Go): TestSearchKeylessMockServer — verifies the keyless path + against the mock server with no API key header. +- **Live**: tests/test_live.py::TestLiveSearchKeyless — hits the real API. """ import json +import os import httpx import pytest @@ -214,3 +221,63 @@ async def test_async_500_raises_internal_server_error(self): body = json.dumps({"detail": "internal server error"}) with pytest.raises(InternalServerErrorResponse): await _async_you(_make_handler(500, body)).search_async(query="python") + + +# --------------------------------------------------------------------------- +# Mock server tests — verify the keyless path against the Go mock server. +# The mock server's POST /v1/agents/search handler accepts requests without +# an X-API-Key header (keyless-capable). These tests require the mock server +# running on localhost:18080 (same as performance tests). +# --------------------------------------------------------------------------- + + +def _mock_server_url() -> str: + return os.getenv("TEST_SERVER_URL", "http://localhost:18080") + + +@pytest.fixture +def mock_server_running(): + """Skip if the mock server isn't running.""" + import socket + + try: + with socket.create_connection(("localhost", 18080), timeout=1): + pass + except (ConnectionRefusedError, OSError): + pytest.skip("Mock server not running on localhost:18080") + + +@pytest.mark.usefixtures("mock_server_running") +class TestSearchKeylessMockServer: + """Keyless search against the Go mock server (no API key).""" + + def test_keyless_search_returns_results(self): + """you.search() with no API key hits /v1/agents/search on the mock server.""" + you = You(server_url=_mock_server_url()) + with you: + res = you.search(query="test query", count=5) + assert res.results is not None + assert res.results.web is not None + assert len(res.results.web) > 0 + + def test_keyless_search_with_string_params(self): + """Keyless search accepts plain string params (country, freshness, safesearch).""" + you = You(server_url=_mock_server_url()) + with you: + res = you.search( + query="AI news", + count=3, + country="US", + freshness="week", + safesearch="moderate", + ) + assert res.results is not None + + @pytest.mark.asyncio + async def test_async_keyless_search(self): + """you.search_async() with no API key works against the mock server.""" + you = You(server_url=_mock_server_url()) + async with you: + res = await you.search_async(query="test query", count=3) + assert res.results is not None + assert res.results.web is not None From 30bfd3fa9e0f59f058d433666d19ea5b61f05842 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 21:12:53 -0700 Subject: [PATCH 32/35] Fix answer model docs to mark Optional fields, run mypy in CI - docs/models/answerresponse.md: web, citations, results now marked Optional to match the code (changed to Optional[List[...]] = None in a previous commit to prevent crash on null API response). - docs/models/answercitation.md: excerpts marked Optional. - docs/models/answersearchresult.md: snippets marked Optional. - .github/workflows/test.yml: removed unused pylint/pyright installs, added mypy step to actually run type checking in CI. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 5 ++++- docs/models/answercitation.md | 2 +- docs/models/answerresponse.md | 6 +++--- docs/models/answersearchresult.md | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f99a73..84368a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install mypy pylint pyright pytest pytest-asyncio + pip install mypy pytest pytest-asyncio - name: Build and start mock server working-directory: tests/mockserver @@ -41,3 +41,6 @@ jobs: - name: Run tests run: pytest tests/ -v --tb=short -x + + - name: Run mypy + run: mypy src/youdotcom/ diff --git a/docs/models/answercitation.md b/docs/models/answercitation.md index f3d7259..69c0d7d 100644 --- a/docs/models/answercitation.md +++ b/docs/models/answercitation.md @@ -8,4 +8,4 @@ A source cited in the answer, with supporting excerpts. | Field | Type | Required | Description | |-------|------|----------|-------------| | `source` | *str* | :heavy_check_mark: | The URL of the cited source. | -| `excerpts` | List[*str*] | :heavy_minus_sign: | Verbatim excerpts from the cited source that support the answer. | +| `excerpts` | Optional[List[*str*]] | :heavy_minus_sign: | Verbatim excerpts from the cited source that support the answer. | diff --git a/docs/models/answerresponse.md b/docs/models/answerresponse.md index d469f56..0367952 100644 --- a/docs/models/answerresponse.md +++ b/docs/models/answerresponse.md @@ -8,12 +8,12 @@ Search results grouped by result type. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `web` | List[[models.AnswerSearchResult](../models/answersearchresult.md)] | :heavy_minus_sign: | All web search results considered during answer synthesis. | +| `web` | Optional[List[[models.AnswerSearchResult](../models/answersearchresult.md)]] | :heavy_minus_sign: | All web search results considered during answer synthesis. | ## AnswerResponse | Field | Type | Required | Description | |-------|------|----------|-------------| | `answer` | *str* | :heavy_check_mark: | The synthesized response with numbered inline citations that reference items in the `citations` array. | -| `citations` | List[[models.AnswerCitation](../models/answercitation.md)] | :heavy_minus_sign: | The sources cited in the answer, in citation order. | -| `results` | [models.AnswerResults](#answerresults) | :heavy_minus_sign: | Search results grouped by result type. | +| `citations` | Optional[List[[models.AnswerCitation](../models/answercitation.md)]] | :heavy_minus_sign: | The sources cited in the answer, in citation order. | +| `results` | [Optional[models.AnswerResults]](#answerresults) | :heavy_minus_sign: | Search results grouped by result type. | diff --git a/docs/models/answersearchresult.md b/docs/models/answersearchresult.md index a45ec70..241633e 100644 --- a/docs/models/answersearchresult.md +++ b/docs/models/answersearchresult.md @@ -9,5 +9,5 @@ A web search result used during answer synthesis. |-------|------|----------|-------------| | `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | | `title` | *str* | :heavy_check_mark: | The title of the source webpage. | -| `snippets` | List[*str*] | :heavy_minus_sign: | Text snippets from the search result that preview its content. | +| `snippets` | Optional[List[*str*]] | :heavy_minus_sign: | Text snippets from the search result that preview its content. | | `page_age` | *Optional[str]* | :heavy_minus_sign: | The publication date or age supplied by the search result. | From e3e2eedbbbe9faec74c38ab9db51fd9acf1850fe Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 21:53:02 -0700 Subject: [PATCH 33/35] Exclude live API tests from CI runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs pytest tests/ which includes test_live.py. The keyless tests in TestLiveSearchKeyless make real network calls to the You.com API without an API key — not appropriate for CI. Added --ignore=tests/test_live.py to the CI test step. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84368a4..021c667 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: curl -sf http://localhost:18080/ || echo "mock server ready" - name: Run tests - run: pytest tests/ -v --tb=short -x + run: pytest tests/ -v --tb=short -x --ignore=tests/test_live.py - name: Run mypy run: mypy src/youdotcom/ From b446ef82c7d6e29e11b70ed61709ed11c2aa6328 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 21:58:08 -0700 Subject: [PATCH 34/35] Fix flaky livecrawl test: treat empty string as valid content The API can return contents.markdown='' (empty string) for some URLs. The old assertion used truthiness (or) which treats '' as falsy. Changed to explicit None checks so empty-string content is accepted. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- tests/test_live.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_live.py b/tests/test_live.py index a612683..b232ea1 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -138,7 +138,8 @@ def test_search_with_livecrawl_web(self, you_client): # Check that we can access the contents field if result.contents: # At least one of html or markdown should be present - assert result.contents.markdown or result.contents.html + # (API may return empty string for some URLs) + assert result.contents.markdown is not None or result.contents.html is not None def test_search_with_livecrawl_news(self, you_client): """Test search with livecrawl for news results (new in 2.2.0).""" From 40ef155ee78f251803e33265e5f0259fc39f539e Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 4 Aug 2026 22:12:16 -0700 Subject: [PATCH 35/35] Fix P0/P1 review findings: AgentType example, Speakeasy header, test confidence P0: docs/sdks/you/README.md agents example used models.AgentType.EXPRESS which does not exist (removed in 2.0.0). ExpressAgentRunsRequest defaults agent='express' internally, so the parameter is not needed. Removed it. P1: tests/__init__.py still had Speakeasy 'DO NOT EDIT' header. Removed. P1: test_search.py used pytest.raises((SpecificError, YouDefaultError)) which passes even if only YouDefaultError is raised (the 4XX fallback). This defeats the tests' stated purpose of locking the error-class contract. Changed to assert the specific error class only. Removed unused YouDefaultError import. P1: CI workflow installed mypy/pytest/pytest-asyncio without version pins. Pinned to match pyproject.toml floors: mypy>=2.3.0, pytest>=9.0.0, pytest-asyncio>=1.0.0. P2: tests/README.md referenced non-existent examples/search.py, examples/contents.py, examples/agents.py. Updated to reference examples/api-example-calls.py. 54 unit tests pass, mypy clean (104 files). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 2 +- docs/sdks/you/README.md | 1 - tests/README.md | 4 +--- tests/__init__.py | 2 -- tests/test_search.py | 14 ++++++-------- 5 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 021c667..3ca03e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -e . - pip install mypy pytest pytest-asyncio + pip install "mypy>=2.3.0" "pytest>=9.0.0" "pytest-asyncio>=1.0.0" - name: Build and start mock server working-directory: tests/mockserver diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index ba3304d..5c189ae 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -57,7 +57,6 @@ with You( api_key_auth=os.getenv("YDC_API_KEY", ""), ) as you: res = you.agents(request=models.ExpressAgentRunsRequest( - agent=models.AgentType.EXPRESS, input="What are the latest AI developments?", )) print(res) diff --git a/tests/README.md b/tests/README.md index b659ae9..d7e388b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -118,9 +118,7 @@ pytest tests/ --ignore=tests/test_live.py -v ## Test Coverage All tests cover the functionality demonstrated in the `examples/` directory: -- ✓ All search examples (`examples/search.py`) -- ✓ All contents examples (`examples/contents.py`) -- ✓ All agents examples (`examples/agents.py`) +- ✓ All API examples (`examples/api-example-calls.py`) Additionally, tests include: - ✓ Error response handling for all endpoints diff --git a/tests/__init__.py b/tests/__init__.py index 368144a..ae78246 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1 @@ -"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" - # tests/__init__.py diff --git a/tests/test_search.py b/tests/test_search.py index db22ddb..19a6812 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -7,12 +7,11 @@ ForbiddenResponseError, UnauthorizedResponseError, UnprocessableEntityResponseError, - YouDefaultError, ) # --------------------------------------------------------------------------- -# Error tests: search() (POST /v1/agents/search) must raise the same +# Error tests: search() (POST /v1/agents/search) must raise the # consolidated *ResponseError classes. Uses MockTransport. # --------------------------------------------------------------------------- @@ -20,9 +19,8 @@ class TestSearchErrors: """Verify search() raises the consolidated *ResponseError classes. - The CHANGELOG documents that the Search endpoint (POST) raises the - consolidated error classes. These tests lock that contract for the - POST path so a regen that mis-wires POST errors would fail CI. + These tests lock the error-class contract so a regen that mis-wires + errors would fail CI. Each test asserts the specific error class. """ def test_unauthorized(self): @@ -36,7 +34,7 @@ def handler(request): transport = httpx.MockTransport(handler) sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="invalid") - with pytest.raises((UnauthorizedResponseError, YouDefaultError)): + with pytest.raises(UnauthorizedResponseError): you.search(query="test") sdk_client.close() @@ -51,7 +49,7 @@ def handler(request): transport = httpx.MockTransport(handler) sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") - with pytest.raises((ForbiddenResponseError, YouDefaultError)): + with pytest.raises(ForbiddenResponseError): you.search(query="test") sdk_client.close() @@ -66,7 +64,7 @@ def handler(request): transport = httpx.MockTransport(handler) sdk_client = httpx.Client(transport=transport) you = You(server_url="http://mock.local", client=sdk_client, api_key_auth="test") - with pytest.raises((UnprocessableEntityResponseError, YouDefaultError)): + with pytest.raises(UnprocessableEntityResponseError): you.search( query="test", include_domains=["example.com"],