diff --git a/CHANGELOG.md b/CHANGELOG.md index c180b91..4664585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,62 @@ 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). +## [3.1.2] - 2026-08-19 + +Sister release track to the extraction-parameter rollout +(3.1.0 + 3.1.1). No breaking changes. All changes are +backward-compatible; everything that worked on 3.1.1 keeps working +unchanged. + +The top-level `from youdotcom import *` surface is narrowed to the +documented public API (`You`, `VERSION`, `OPENAPI_DOC_VERSION`, +`USER_AGENT`). Previously re-exported names like `SDKConfiguration` +remain available via their submodules (e.g., +`from youdotcom.sdkconfiguration import SDKConfiguration`). + +### Fixed + +- **Models are usable inside a Temporal Workflow.** + `youdotcom/__init__.py` no longer eagerly pulls 306 modules + (including `httpx` and `urllib.request`), so a Workflow module that + does `from youdotcom.models import SearchResponse` (no + `workflow.unsafe.imports_passed_through()` work-around) prepares + cleanly under the default `SandboxedWorkflowRunner`. PEP 562 module + `__getattr__` mirrors the public-import surface without dragging + transport; the `models/__init__.py` lazy pattern shipped in 3.0.0 was + lifted to the package root. Regression coverage lives in + `tests/test_root_init.py` (subprocess assertion: `import youdotcom` + does not load `httpx` / `urllib.request`). +- **`ResearchTaskStreamEvent.event` accepts future SSE event names.** + The discriminator field widens from `Event` to + `Union[Event, UnrecognizedStr]`, so a server-side event-name addition + (a new terminal status, a retry signal, anything the SDK does not + yet enumerate) does not raise `ResponseValidationError` on the + unmarshal path. Known event names still resolve to the `Event` enum + member; unknown names unwrap as a `str` subclass that compares equal + to its raw value, so callers branching on raw strings + (`evt.event == "completed"`) keep working unchanged. + Exhaustive-enumeration callers (`isinstance(evt.event, Event)`) get + the right negative answer. Coverage in + `tests/test_researchtaskstreamevent.py`. + +### Added + +- **Attribution header `X-Client-Info` on every outbound request.** + New optional `You(app_title=..., app_url=...)` constructor + args populate the `title=` and `url=` segments. Wire format: + + python-sdk; client=youdotcom/; title=; url=<url>; ua=python/<V> httpx/<V> + + so the analytics layer can distinguish SDK traffic from other + sources. +- **`safesearch` parameter on `You.answer()`.** + The Answer API now supports the same explicit-content filtering + as the Web Search API. New optional `safesearch` kwarg on + `answer()` and `answer_async()` accepts ``off``, ``moderate`` + (default), or ``strict``. Case-insensitive, like the search + counterpart. Existing call sites are unaffected. + ## [3.1.1] - 2026-08-12 ### Fixed diff --git a/README.md b/README.md index 9413a0c..99d9693 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ A synthesized answer with citations, grounded in live web results. res = you.answer( query="What are the tradeoffs of vector vs. keyword search?", freshness="month", + safesearch="strict", include_domains=["arxiv.org"], ) @@ -356,6 +357,33 @@ is the exception: the helpers under [Long-running research](#long-running-research) manage their own deadlines, so `timeout_s` there bounds the wait rather than `timeout_ms`. +### Attribution + +Every SDK request emits an `X-Client-Info` header so the analytics layer can +split SDK traffic from MCP traffic. The wire format is: + +``` +python-sdk; client=youdotcom/<version>[; title=<title>][; url=<url>]; ua=python/<V> httpx/<V> +``` + +Optionally pass `app_title` and `app_url` to populate the `title=` and +`url=` segments: + +```python +import os +from youdotcom import You + +with You( + api_key_auth=os.getenv("YDC_API_KEY"), + app_title="MyAgent", + app_url="https://example.com", + timeout_ms=60_000, +) as you: + res = you.search(query="...") +``` + +Both arguments are optional; existing call sites are unaffected. + ### Servers `search` and `contents` go to `https://ydc-index.io`. Everything else goes to diff --git a/USAGE.md b/USAGE.md index 34efedd..8fbccec 100644 --- a/USAGE.md +++ b/USAGE.md @@ -91,4 +91,35 @@ The `extraction` parameter replaces the deprecated `livecrawl` / Unknown keys inside `extraction` raise `ValidationError` locally, and passing `extraction` together with `livecrawl` / `livecrawl_formats` raises `ValueError` — both mirror the server's 422 contract so callers fail-fast. -<!-- End SDK Example Usage [extraction] --> \ No newline at end of file +<!-- End SDK Example Usage [extraction] --> + +<!-- Start SDK Example Usage [attribution] --> +```python +# Tag every outbound request with a caller-identity header so the +# analytics layer can split SDK traffic from MCP traffic. +import os +from youdotcom import You + + +with You( + api_key_auth=os.getenv("YDC_API_KEY"), + app_title="MyAgent", + app_url="https://example.com", + timeout_ms=60_000, +) as you: + + res = you.search(query="What did OpenAI announce this week?") + + # Handle response + print(res) +``` + +`X-Client-Info` sent on the wire: + +``` +python-sdk; client=youdotcom/<version>; title=MyAgent; url=https://example.com; ua=python/<V> httpx/<V> +``` + +`app_title` and `app_url` are optional. When omitted, those segments are +dropped entirely. +<!-- End SDK Example Usage [attribution] --> \ No newline at end of file diff --git a/docs/models/answerrequestbody.md b/docs/models/answerrequestbody.md index 2015fc9..04dbcfc 100644 --- a/docs/models/answerrequestbody.md +++ b/docs/models/answerrequestbody.md @@ -11,6 +11,7 @@ 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. | +| `safesearch` | [Optional[models.SafeSearch]](../models/safesearch.md) | :heavy_minus_sign: | Configures the safesearch filter for content moderation. `off`, `moderate` (default), or `strict`. | | `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. | diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 584c6f5..6630cb3 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -42,6 +42,19 @@ with You( print(res) ``` +### Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `query` | *str* | :heavy_check_mark: | The search query. Max 400 characters. | +| `freshness` | *str* | :heavy_minus_sign: | `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD`. | +| `country` | *str* | :heavy_minus_sign: | Country code (e.g. `US`, `GB`). Case-insensitive. | +| `language` | *str* | :heavy_minus_sign: | BCP 47 language tag (e.g. `EN`, `FR`). Case-insensitive. | +| `safesearch` | *str* | :heavy_minus_sign: | Explicit-content filtering: `off`, `moderate` (default), or `strict`. Case-insensitive. | +| `include_domains` | List[*str*] | :heavy_minus_sign: | Only return results from these domains. Max 500. | +| `exclude_domains` | List[*str*] | :heavy_minus_sign: | Exclude results from these domains. Max 500. | +| `boost_domains` | List[*str*] | :heavy_minus_sign: | Prefer results from these domains. Max 500. | + ## search Search via `POST /v1/search`. Returns unified search results from web and news sources. Requires an API key. Country and language accept plain strings and are normalized to uppercase. diff --git a/pyproject.toml b/pyproject.toml index 1f093e7..49581b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "youdotcom" -version = "3.1.1" +version = "3.1.2" description = "The official You.com Python SDK." authors = [{ name = "You.com" },] readme = "README.md" diff --git a/src/youdotcom/__init__.py b/src/youdotcom/__init__.py index 4153b35..4439dad 100644 --- a/src/youdotcom/__init__.py +++ b/src/youdotcom/__init__.py @@ -1,15 +1,99 @@ +"""Public surface for ``youdotcom``. +Imports are resolved lazily via PEP 562 module ``__getattr__`` so that +``import youdotcom`` does **not** pull transport-layer modules +(``httpx``, ``urllib.request``) into ``sys.modules``. This matters for +Temporal Workflow sandboxes, which reject transport imports at Worker +construction time and cannot be patched around with +``workflow.unsafe.imports_passed_through()`` because the parent package +import runs before any submodule body. + +Public surface (trying out ``from youdotcom import <name>``): + +- ``You`` — the unified API client (from ``.sdk``) +- ``VERSION`` / ``OPENAPI_DOC_VERSION`` / ``USER_AGENT`` — version pins + populated from ``_version.py`` at module load + +Sub-packages accessed as ``youdotcom.<name>.X``: + +- ``models``, ``errors``, ``utils``, ``types``, ``_hooks``, ``_shims`` + +Lazy-init port. Mirrors the pattern used in +``youdotcom.models.__init__`` (shipped in 3.0.0) at the SDK root. +""" + +from typing import Any, TYPE_CHECKING + +from youdotcom.utils.dynamic_imports import lazy_getattr, lazy_dir from ._version import ( - __title__, - __version__, __openapi_doc_version__, + __title__, __user_agent__, + __version__, ) -from .sdk import * -from .sdkconfiguration import * +if TYPE_CHECKING: + from .sdk import You + +__all__ = [ + "OPENAPI_DOC_VERSION", + "USER_AGENT", + "VERSION", + "You", + "__openapi_doc_version__", + "__title__", + "__user_agent__", + "__version__", +] + + +# Explicit module-level constants. These are cheap strings resolved +# eagerly from ``_version.py``, which doesn't pull transport-layer +# modules. Keeping them as real attributes (vs. routing through +# ``__getattr__``) preserves `from youdotcom import VERSION` ergonomics +# and avoids the overhead of an indirection on a one-line lookup. VERSION: str = __version__ -OPENAPI_DOC_VERSION = __openapi_doc_version__ -USER_AGENT = __user_agent__ +OPENAPI_DOC_VERSION: str = __openapi_doc_version__ +USER_AGENT: str = __user_agent__ + + +# Lazy mapping for the single non-constant public attribute, ``You``. +_dynamic_imports: dict[str, str] = { + "You": ".sdk", +} + + +# Sub-packages accessible as ``youdotcom.<name>`` (PEP 562 routes the +# attribute lookup through ``__getattr__`` so the submodule is imported +# on demand, the first time someone touches it). +_sub_packages: list[str] = [ + "_hooks", + "_shims", + "errors", + "models", + "types", + "utils", +] + + +def __getattr__(attr_name: str) -> Any: + return lazy_getattr( + attr_name, + package=__package__, + dynamic_imports=_dynamic_imports, + sub_packages=_sub_packages, + ) + + +def __dir__(): + return sorted( + set( + lazy_dir( + dynamic_imports=_dynamic_imports, + sub_packages=_sub_packages, + ) + ) + | set(__all__) + ) diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index 06cf118..74b4756 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -2,7 +2,7 @@ import importlib.metadata __title__: str = "youdotcom" -__version__: str = "3.1.0" +__version__: str = "3.1.2" __openapi_doc_version__: str = "1.0.0" try: diff --git a/src/youdotcom/basesdk.py b/src/youdotcom/basesdk.py index d91eb22..bb4564e 100644 --- a/src/youdotcom/basesdk.py +++ b/src/youdotcom/basesdk.py @@ -14,6 +14,7 @@ from youdotcom.utils import ( RetryConfig, SerializedRequestBody, + build_client_info_header, get_body_content, run_sync_in_thread, ) @@ -199,6 +200,14 @@ def _build_request_with_client( headers = utils.get_headers(request, _globals) headers["Accept"] = accept_header_value headers[user_agent_header] = self.sdk_configuration.user_agent + # ``X-Client-Info`` attribution header, set at the same single + # site as ``User-Agent`` — every endpoint funnels through + # ``_build_request_with_client``, so a single construction + # point prevents per-endpoint drift. + headers["X-Client-Info"] = build_client_info_header( + app_title=self.sdk_configuration.app_title, + app_url=self.sdk_configuration.app_url, + ) if security is not None: if callable(security): diff --git a/src/youdotcom/models/answerrequestbody.py b/src/youdotcom/models/answerrequestbody.py index 90a16c6..b98ee5e 100644 --- a/src/youdotcom/models/answerrequestbody.py +++ b/src/youdotcom/models/answerrequestbody.py @@ -2,6 +2,7 @@ from .country import Country from .freshnessvalue import FreshnessValue from .language import Language +from .safesearch import SafeSearch from pydantic import model_serializer from typing import List, Optional from youdotcom.types import BaseModel, UNSET_SENTINEL @@ -22,6 +23,9 @@ class AnswerRequestBody(BaseModel): language: Optional[Language] = None r"""A supported BCP 47 language tag that determines the language of the web results.""" + safesearch: Optional[SafeSearch] = None + r"""Configures the safesearch filter for content moderation. ``off``, ``moderate`` (default), or ``strict``.""" + include_domains: Optional[List[str]] = None r"""Domains to exclusively include. Cannot combine with ``exclude_domains`` or ``boost_domains``. Max 500.""" @@ -34,7 +38,7 @@ class AnswerRequestBody(BaseModel): @model_serializer(mode="wrap") def serialize_model(self, handler): optional_fields = set( - ["freshness", "country", "language", "include_domains", "exclude_domains", "boost_domains"] + ["freshness", "country", "language", "safesearch", "include_domains", "exclude_domains", "boost_domains"] ) serialized = handler(self) m = {} diff --git a/src/youdotcom/models/researchtaskstreamevent.py b/src/youdotcom/models/researchtaskstreamevent.py index 6596a05..b187851 100644 --- a/src/youdotcom/models/researchtaskstreamevent.py +++ b/src/youdotcom/models/researchtaskstreamevent.py @@ -3,13 +3,26 @@ from __future__ import annotations from enum import Enum from pydantic import model_serializer -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union from typing_extensions import NotRequired, TypedDict -from youdotcom.types import BaseModel, Nullable, OptionalNullable, UNSET, UNSET_SENTINEL +from youdotcom.types import ( + BaseModel, + Nullable, + OptionalNullable, + UNSET, + UNSET_SENTINEL, + UnrecognizedStr, +) class Event(str, Enum): r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + + Note: this enum is **not** an exhaustive list. The SDK accepts + unknown event names as :class:`UnrecognizedStr` so that a future + server-side event addition does not break unmarshal. Equality + checks against a known name (``evt.event == "connected"``) keep + working because :class:`UnrecognizedStr` inherits from :class:`str`. """ CONNECTED = "connected" @@ -21,6 +34,15 @@ class Event(str, Enum): CANCELLED = "cancelled" +# Public alias exposed alongside :class:`Event`. The TypedDict surfaces it +# so callers reading IDE/help see that any string is accepted on the wire, +# not just the enum members. The Pydantic runtime model widens further to +# ``Union[Event, UnrecognizedStr]`` so unknown event names unmarshal as +# :class:`UnrecognizedStr` (lax-mode fallback, see +# ``youdotcom.types.UnrecognizedStr``). +EventName = Union[Event, str] # IDE-facing type for the SSE event discriminator + + class ResearchTaskStreamEventDataTypedDict(TypedDict): r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" @@ -92,8 +114,8 @@ class ResearchTaskStreamEventTypedDict(TypedDict): id: str r"""Sequence number of the SSE event.""" - event: Event - r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + event: EventName + r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. Unknown event names are accepted as plain strings so future server-side additions unmarshal cleanly. """ data: ResearchTaskStreamEventDataTypedDict r"""The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence.""" @@ -105,8 +127,16 @@ class ResearchTaskStreamEvent(BaseModel): id: str r"""Sequence number of the SSE event.""" - event: Event + event: Union[Event, UnrecognizedStr] r"""The type of the SSE event. Terminal events that close the stream are: `response.done`, `complete`, `error`, and `cancelled`. The stream may also emit `completed`, `failed`, or `cancelled` as event names corresponding to the task's terminal status. + + Unknown event names are accepted as :class:`UnrecognizedStr` so that + a future server-side event addition does not raise + ``ResponseValidationError`` on the unmarshal path. Callers that + branch on a known name (``evt.event == "completed"``) keep working + unchanged because :class:`UnrecognizedStr` is a :class:`str` + subclass; ``isinstance(evt.event, Event)`` is the right check when + the caller wants to enumerate exhaustively. """ data: ResearchTaskStreamEventData diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 56cdd1e..22fac3d 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -197,6 +197,8 @@ def __init__( retry_config: OptionalNullable[RetryConfig] = UNSET, timeout_ms: Optional[int] = None, debug_logger: Optional[Logger] = None, + app_title: Optional[str] = None, + app_url: Optional[str] = None, ) -> None: r"""Instantiates the SDK configuring it with the provided parameters. @@ -208,6 +210,12 @@ def __init__( :param async_client: The Async HTTP client to use for all asynchronous methods :param retry_config: The retry configuration to use for all supported methods :param timeout_ms: Optional request timeout applied to each operation in milliseconds + :param app_title: Optional caller-identity title for the ``X-Client-Info`` + attribution header. Surfaces in the analytics layer as + the ``title=`` segment. Defaults to ``None`` → segment dropped. + :param app_url: Optional caller-identity URL for the ``X-Client-Info`` + attribution header. Surfaces in the analytics layer as + the ``url=`` segment. Defaults to ``None`` → segment dropped. """ client_supplied = True if client is None: @@ -276,6 +284,8 @@ def _resolve_security() -> models.Security: retry_config=retry_config, timeout_ms=timeout_ms, debug_logger=debug_logger, + app_title=app_title, + app_url=app_url, ), parent_ref=self, ) @@ -376,6 +386,7 @@ def answer( ] = None, country: Optional[Union[str, models.Country]] = None, language: Optional[Union[str, models.Language]] = None, + safesearch: Optional[str] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -386,10 +397,10 @@ def answer( ) -> 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. + Provide a ``query`` and optional freshness, locale, domain, and + explicit-content 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 @@ -400,6 +411,8 @@ def answer( focus of the web results. :param language: A supported BCP 47 language tag that determines the language of the web results. + :param safesearch: Explicit-content filtering: ``off``, ``moderate`` + (default), or ``strict``. Case-insensitive. :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 @@ -427,6 +440,7 @@ def answer( freshness=_lower(freshness), country=_upper(country), language=_upper(language), + safesearch=_lower(safesearch), 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]]), @@ -524,6 +538,7 @@ async def answer_async( ] = None, country: Optional[Union[str, models.Country]] = None, language: Optional[Union[str, models.Language]] = None, + safesearch: Optional[str] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -534,10 +549,10 @@ async def answer_async( ) -> 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. + Provide a ``query`` and optional freshness, locale, domain, and + explicit-content 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 @@ -548,6 +563,8 @@ async def answer_async( focus of the web results. :param language: A supported BCP 47 language tag that determines the language of the web results. + :param safesearch: Explicit-content filtering: ``off``, ``moderate`` + (default), or ``strict``. Case-insensitive. :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 @@ -575,6 +592,7 @@ async def answer_async( freshness=_lower(freshness), country=_upper(country), language=_upper(language), + safesearch=_lower(safesearch), 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]]), diff --git a/src/youdotcom/sdkconfiguration.py b/src/youdotcom/sdkconfiguration.py index 049fba3..2f1f2c9 100644 --- a/src/youdotcom/sdkconfiguration.py +++ b/src/youdotcom/sdkconfiguration.py @@ -35,6 +35,13 @@ class SDKConfiguration: user_agent: str = __user_agent__ retry_config: OptionalNullable[RetryConfig] = field(default_factory=lambda: UNSET) timeout_ms: Optional[int] = None + # Optional caller-identity fields consumed by + # ``utils.attribution.build_client_info_header`` and emitted in the + # ``X-Client-Info`` header on every outbound request. Both default + # to ``None`` so existing callers (and every existing test) keep + # working without any change. + app_title: Optional[str] = None + app_url: Optional[str] = None def get_server_details(self) -> Tuple[str, Dict[str, str]]: if self.server_url is not None and self.server_url: diff --git a/src/youdotcom/utils/__init__.py b/src/youdotcom/utils/__init__.py index 81eacf5..f9b9757 100644 --- a/src/youdotcom/utils/__init__.py +++ b/src/youdotcom/utils/__init__.py @@ -14,6 +14,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T: if TYPE_CHECKING: + from .attribution import build_client_info_header from .annotations import get_discriminator from .datetimes import parse_datetime, parse_duration from .enums import OpenEnumMeta @@ -63,6 +64,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T: __all__ = [ "BackoffStrategy", + "build_client_info_header", "FieldMetadata", "find_metadata", "FormMetadata", @@ -117,6 +119,7 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T: _dynamic_imports: dict[str, str] = { "BackoffStrategy": ".retries", + "build_client_info_header": ".attribution", "FieldMetadata": ".metadata", "find_metadata": ".metadata", "FormMetadata": ".metadata", diff --git a/src/youdotcom/utils/attribution.py b/src/youdotcom/utils/attribution.py new file mode 100644 index 0000000..da5f139 --- /dev/null +++ b/src/youdotcom/utils/attribution.py @@ -0,0 +1,79 @@ +"""Build the ``X-Client-Info`` header value for outbound SDK requests. + +Emits a caller-identity header so the analytics layer can distinguish +SDK traffic from other sources. SDK traffic is uniquely identified by +the leading literal ``python-sdk``. + +``build_client_info_header`` is called per-request from +``BaseSDK._build_request_with_client`` immediately after the +``User-Agent`` header is set. It does no module-level +``httpx``/``urllib`` imports — both are pulled in lazily at the +top of the function body so that ``import youdotcom`` does +not regress because of this module. +""" + +from __future__ import annotations + +import sys +from typing import Optional + + +def build_client_info_header( + *, + app_title: Optional[str] = None, + app_url: Optional[str] = None, +) -> str: + r"""Build the ``X-Client-Info`` header value for an outbound SDK request. + + Grammar (segments joined by ``"; "``): + + python-sdk; client=youdotcom/<version>[; title=<title>][; url=<url>]; ua=python/<V> httpx/<V> + + Optional segments are dropped entirely (no leading/trailing + ``"; "`` left behind, no empty ``=``) when their value is + ``None``. The ``title=``/``url=``/``ua=`` segments may legally + contain ``=`` (e.g., query strings in URLs), which the trailing + semicolons preserve. + + Args: + app_title: Optional caller-facing application title. Falls back + to None → ``title=`` segment is dropped. + app_url: Optional caller-facing application URL. Falls back + to None → ``url=`` segment is dropped. ``?x=1``-style query + strings survive the segment delimiter because the analytics + parser splits on the *first* ``=`` only. + + Returns: + The header value to send over the wire. Empty segment handles + do not show up. + + Side effects: + Lazily imports ``httpx`` and ``youdotcom`` to inspect their + version metadata. Both are already loaded by the time + ``You.search(...)`` runs an actual request, so this is a + no-op lookup in practice — but the lazy form keeps the + import-time footprint of ``youdotcom`` minimal + (``import youdotcom`` does not load ``httpx``). + """ + # pylint: disable=import-outside-toplevel # lazy to keep httpx out + # of ``sys.modules`` at import time. + import httpx + import youdotcom + + parts: list[str] = ["python-sdk"] + + parts.append(f"client=youdotcom/{youdotcom.__version__}") + + if app_title is not None: + parts.append(f"title={app_title}") + + if app_url is not None: + parts.append(f"url={app_url}") + + py = sys.version_info + parts.append( + f"ua=python/{py.major}.{py.minor}.{py.micro} " + f"httpx/{httpx.__version__}" + ) + + return "; ".join(parts) diff --git a/tests/test_answer.py b/tests/test_answer.py index 2cec3b4..263fc25 100644 --- a/tests/test_answer.py +++ b/tests/test_answer.py @@ -190,6 +190,7 @@ def handler(request): body = captured["body"] assert "freshness" not in body assert "country" not in body + assert "safesearch" not in body assert "include_domains" not in body assert body["query"] == "test" @@ -201,6 +202,32 @@ async def test_async_returns_answer_response(self): assert len(res.citations) == 2 assert len(res.results.web) == 2 + def test_safesearch_sent_on_wire(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + with _sync_you(handler) as you: + you.answer(query="test", safesearch="strict") + assert captured["body"]["safesearch"] == "strict" + + def test_safesearch_omitted_when_not_passed(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, content=_ANSWER_BODY + ) + + with _sync_you(handler) as you: + you.answer(query="test") + assert "safesearch" not in captured["body"] + class TestAnswerErrors: def test_402_raises_payment_required_error(self): diff --git a/tests/test_attribution.py b/tests/test_attribution.py new file mode 100644 index 0000000..e7afefa --- /dev/null +++ b/tests/test_attribution.py @@ -0,0 +1,216 @@ +"""Tests for the ``X-Client-Info`` attribution header. + +Locks two contracts: + +1. ``youdotcom.utils.attribution.build_client_info_header`` produces the + exact wire format — leading ``python-sdk`` token, + the four optional segments in the canonical order, ``"; "`` separator + throughout, no leading/trailing separators, no empty segments when + the optional args are ``None``. Each token survives ``=`` characters + in the value (e.g. ``url=https://example.com?x=1``) and ``;`` + characters never leak into a value. + +2. ``BaseSDK._build_request_with_client`` writes ``X-Client-Info`` at + the same site as ``User-Agent``, every endpoint routes through it, + so a per-endpoint drift is impossible. Exercised via ``MockTransport`` + round-trips since the established test pattern calls + ``You.search(...)`` against a mock and inspects + ``request.headers``. +""" + +from __future__ import annotations + +import json +import sys + +import httpx +import pytest + +from youdotcom import You +from youdotcom.utils.attribution import build_client_info_header + + +_SEARCH_BODY = json.dumps({"results": {"web": []}}) + + +# --------------------------------------------------------------------------- +# Pure helper tests — drive ``build_client_info_header`` directly. +# --------------------------------------------------------------------------- + + +class TestBuildClientInfoHeaderGrammar: + """Locks the grammar portion of the wire format spec. + + These tests pin the exact wire format so a regression is caught + at unit-test time. + """ + + def test_leading_token_is_python_sdk(self): + out = build_client_info_header() + assert out.startswith("python-sdk; "), out + + def test_default_call_has_only_required_segments(self): + out = build_client_info_header() + # Required: python-sdk; client=…; ua=… + # Optional (None drops segment): title=, url= + assert out == ( + f"python-sdk; client=youdotcom/{__import__('youdotcom').__version__}; " + f"ua=python/{sys.version_info.major}.{sys.version_info.minor}." + f"{sys.version_info.micro} httpx/{httpx.__version__}" + ) + + def test_app_title_appended_after_client(self): + out = build_client_info_header(app_title="MyAgent") + # title= comes after client= and before ua= + parts = out.split("; ") + assert parts[0] == "python-sdk" + assert parts[1].startswith("client=youdotcom/") + assert "title=MyAgent" in parts + # ua= stays at the end + assert parts[-1].startswith("ua=python/") + + def test_app_url_appended_after_title(self): + out = build_client_info_header(app_title="MyAgent", app_url="https://example.com") + # canonical order: python-sdk, client=, title=, url=, ua= + parts = out.split("; ") + assert parts[0] == "python-sdk" + assert parts[1].startswith("client=youdotcom/") + assert parts[2] == "title=MyAgent" + assert parts[3] == "url=https://example.com" + assert parts[-1].startswith("ua=python/") + + def test_no_extra_separators_when_optional_segments_dropped(self): + # app_title=None and app_url=None: no ``; ;``, no leading ``;``, + # no trailing ``;``, no empty ``=``. + out = build_client_info_header() + assert "; ;" not in out + assert not out.startswith("; ") + assert not out.endswith("; ") + assert "=;" not in out + assert "; ; " not in out + + def test_url_with_query_string_survives_segment_split(self): + # URL values with query strings contain ``=``; pin that the + # value stays intact so the SDK never feeds malformed segments. + out = build_client_info_header(app_url="https://example.com?x=1&y=2") + # Parse the segment by re-splitting at the first occurrence of + # "url=" and reading until the next "; " boundary. + url_seg_start = out.index("url=") + len("url=") + # Trailing segment is ``ua=…``; its prefix ``; ua=`` is the + # unambiguous separator. + url_seg = out[url_seg_start: out.index("; ua=")] + assert url_seg == "https://example.com?x=1&y=2" + + def test_ua_segment_contains_python_and_httpx_versions(self): + out = build_client_info_header() + ua_seg = out[out.index("ua=") + len("ua="):] + assert ua_seg.startswith(f"python/{sys.version_info.major}") + assert f" httpx/{httpx.__version__}" in ua_seg + + def test_client_segment_uses_youdotcom_version(self): + import youdotcom + + out = build_client_info_header() + assert f"client=youdotcom/{youdotcom.__version__}" in out + + +class TestBuildClientInfoHeaderEdgeCases: + """Edge-case handling for the optional segments.""" + + @pytest.mark.parametrize( + "title,url", + [ + ("MyAgent", "https://example.com"), + ("Spaces In Title", "https://example.com/path?q=v"), + ("Special&Chars!", "https://example.com/?foo=bar&baz=qux"), + ], + ) + def test_round_trip_through_grammar(self, title, url): + # Sanity: any pair (title, url) reproduces the canonical order, + # client/title/url/ua all present and segments are intact. + out = build_client_info_header(app_title=title, app_url=url) + parts = out.split("; ") + assert parts[0] == "python-sdk" + assert parts[1].startswith("client=") + assert parts[2] == f"title={title}" + assert parts[3] == f"url={url}" + assert parts[4].startswith("ua=") + + +# --------------------------------------------------------------------------- +# Round-trip tests — header makes it onto the wire via _build_request_with_client. +# --------------------------------------------------------------------------- + + +class TestWireRoundTrip: + """``X-Client-Info`` must land on the wire for every outbound request.""" + + def test_search_sets_x_client_info(self): + captured: dict = {} + + def handler(request): + captured["headers"] = {k.lower(): v for k, v in request.headers.items()} + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=_SEARCH_BODY, + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with You( + api_key_auth="k", + server_url="http://mock.local", + client=client, + timeout_ms=10_000, + ) as you: + you.search(query="q") + finally: + client.close() + + assert "x-client-info" in captured["headers"], ( + f"X-Client-Info not on the wire. Headers: {sorted(captured['headers'].keys())}" + ) + # And the value matches the helper's output for default args. + from youdotcom import __version__ + + expected = ( + f"python-sdk; client=youdotcom/{__version__}; " + f"ua=python/{sys.version_info.major}.{sys.version_info.minor}." + f"{sys.version_info.micro} httpx/{httpx.__version__}" + ) + assert captured["headers"]["x-client-info"] == expected + + def test_app_title_and_url_propagate_to_wire(self): + captured: dict = {} + + def handler(request): + captured["headers"] = {k.lower(): v for k, v in request.headers.items()} + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=_SEARCH_BODY, + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with You( + api_key_auth="k", + server_url="http://mock.local", + client=client, + timeout_ms=10_000, + app_title="MyAgent", + app_url="https://example.com", + ) as you: + you.search(query="q") + finally: + client.close() + + info = captured["headers"]["x-client-info"] + assert "title=MyAgent" in info + assert "url=https://example.com" in info + # Order: python-sdk; client=; title=; url=; ua= + parts = info.split("; ") + assert parts[0] == "python-sdk" + assert parts[2] == "title=MyAgent" + assert parts[3] == "url=https://example.com" diff --git a/tests/test_live.py b/tests/test_live.py index 87d33e1..894e4d3 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -862,6 +862,17 @@ def test_answer_with_boost_domains(self, you_client): assert isinstance(res, AnswerResponse) assert len(res.answer) > 0 + def test_answer_with_safesearch(self, you_client): + """Test answer with safesearch content filter.""" + with you_client as you: + res = you.answer( + query="Latest science news", + safesearch=SafeSearch.STRICT, + ) + + assert isinstance(res, AnswerResponse) + assert len(res.answer) > 0 + @pytest.mark.asyncio async def test_async_answer(self, you_client): """Test async you.answer_async().""" diff --git a/tests/test_param_normalization.py b/tests/test_param_normalization.py index 945ae2a..cdc0365 100644 --- a/tests/test_param_normalization.py +++ b/tests/test_param_normalization.py @@ -143,9 +143,13 @@ def test_country_and_language_upper(self): def test_freshness_lower(self): assert _answer_body(freshness="MONTH")["freshness"] == "month" + @pytest.mark.parametrize("value", ["strict", "STRICT", SafeSearch.STRICT]) + def test_safesearch_normalizes_to_lower(self, value): + assert _answer_body(safesearch=value)["safesearch"] == "strict" + def test_optional_params_omitted_when_unset(self): body = _answer_body() - for field in ("country", "language", "freshness"): + for field in ("country", "language", "freshness", "safesearch"): assert field not in body diff --git a/tests/test_researchtaskstreamevent.py b/tests/test_researchtaskstreamevent.py new file mode 100644 index 0000000..dc24400 --- /dev/null +++ b/tests/test_researchtaskstreamevent.py @@ -0,0 +1,172 @@ +"""Tests for ``ResearchTaskStreamEvent`` model-level contracts. + +The SSE ``event`` discriminator must accept any string the server +emits, including names the SDK does not enumerate. The strict ``Event`` +enum is preserved for backwards compatibility on the Python surface +(catchable via ``isinstance`` / equality, IDE autocomplete still +narrow), and unknown names flow through as +:class:`youdotcom.types.UnrecognizedStr` instances that compare equal +to their raw string value. + +The regression scenario: the server introduces a +new SSE event name (e.g. ``retry``, ``checkpoint``) and the SDK stops +raising ``ResponseValidationError`` on the unmarshal path. This test +suite pins both halves of the contract: + +- Known event names still resolve to :class:`Event` enum members. +- Unknown event names resolve to :class:`UnrecognizedStr` instances + that compare equal to their raw string. +""" + +from __future__ import annotations + +import pytest + +from youdotcom.models.researchtaskstreamevent import ( + Event, + ResearchTaskStreamEvent, +) +from youdotcom.types import UnrecognizedStr + + +_EVENT_DATA = {"type": "delta", "task_id": "t-1", "status": "running"} + + +class TestResearchTaskStreamEventKnown: + """Known event names still resolve to Event enum members.""" + + @pytest.mark.parametrize( + "event_name", + [ + "connected", + "response.done", + "complete", + "completed", + "error", + "failed", + "cancelled", + ], + ) + def test_known_event_name_resolves_to_event_enum(self, event_name: str) -> None: + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": event_name, "data": _EVENT_DATA} + ) + assert isinstance(evt.event, Event) + assert evt.event.value == event_name + + def test_known_event_equality_check_preserves_compare_eq_str(self) -> None: + """``evt.event == 'completed'`` keeps working when the input is a known Event member. + + This is the explicit backwards-compat promise: callers + who match on the raw string identifier do not need to update. + """ + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "completed", "data": _EVENT_DATA} + ) + assert isinstance(evt.event, Event) + assert evt.event == "completed" + assert evt.event.value == "completed" + + +class TestResearchTaskStreamEventUnknown: + """Unknown event names survive unmarshal as ``UnrecognizedStr``.""" + + @pytest.mark.parametrize( + "future_event_name", + [ + "retry", + "checkpoint", + "completely.new.event.we.dont.know.about", + "0x-prefixed-thing", + ], + ) + def test_unknown_event_name_resolves_to_unrecognized_str( + self, future_event_name: str + ) -> None: + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": future_event_name, "data": _EVENT_DATA} + ) + assert isinstance(evt.event, UnrecognizedStr) + assert not isinstance(evt.event, Event) + + def test_unknown_event_equality_against_raw_string(self) -> None: + """``evt.event == 'whatever'`` returns True even when UnrecognizedStr wraps it. + + Backwards-compat: callers branching on raw string identifiers + (the documented contract of the discriminator field) keep + working without changes. + """ + evt = ResearchTaskStreamEvent.model_validate( + { + "id": "1", + "event": "retry", + "data": _EVENT_DATA, + } + ) + assert evt.event == "retry" + + def test_unknown_event_equality_against_known_event_name(self) -> None: + """Equality against an unrelated known name returns False.""" + evt = ResearchTaskStreamEvent.model_validate( + { + "id": "1", + "event": "retry", + "data": _EVENT_DATA, + } + ) + # Cross-name comparison must not return True just because both + # back onto ``str``. If this assertion fires, UnrecognizedStr + # has been over-engineered into something that coerces strings + # onto enum-equivalent values. + assert evt.event != "completed" + assert evt.event != "connected" + + def test_unknown_event_isinstance_event_returns_false(self) -> None: + """``isinstance(evt.event, Event)`` returns False for unknown names. + + Callers that want exhaustive enumeration can still fall back to + the strict mode and detect unknown names by the absence of + ``Event`` membership. This is exactly the ``isinstance`` check + the design recommends. + """ + evt = ResearchTaskStreamEvent.model_validate( + { + "id": "1", + "event": "checkpoint", + "data": _EVENT_DATA, + } + ) + assert isinstance(evt.event, UnrecognizedStr) + assert not isinstance(evt.event, Event) + + def test_unknown_event_membership_check_in_set(self) -> None: + """``evt.event in {'a', 'b', 'retry'}`` works for UnrecognizedStr. + + ``__contains__`` on a string set delegates to ``__eq__`` on the + string subclass; this confirms the discriminator can be + filtered through ``in`` membership tests without losing + coverage on unknown values. + """ + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "retry", "data": _EVENT_DATA} + ) + assert evt.event in {"retry", "checkpoint"} + assert evt.event not in {"completed", "failed"} + + +class TestResearchTaskStreamEventRoundTrip: + """Known and unknown events round-trip to JSON identically.""" + + def test_known_event_round_trips_to_value_string(self) -> None: + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "completed", "data": _EVENT_DATA} + ) + dumped = evt.model_dump(by_alias=True) + assert dumped["event"] == "completed" + + def test_unknown_event_round_trips_to_value_string(self) -> None: + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "retry", "data": _EVENT_DATA} + ) + dumped = evt.model_dump(by_alias=True) + assert dumped["event"] == "retry" diff --git a/tests/test_root_init.py b/tests/test_root_init.py new file mode 100644 index 0000000..5a1be2d --- /dev/null +++ b/tests/test_root_init.py @@ -0,0 +1,113 @@ +"""Tests for the ``youdotcom`` package root module. + +Importing the package must not pull transport-layer modules +(``httpx``, ``urllib.request``) into ``sys.modules``. This matters for +Temporal Workflow sandboxes, which reject transport imports at Worker +construction time and cannot be patched around with +``workflow.unsafe.imports_passed_through()`` because the parent package +import runs before any submodule body. + +The transport invariant is enforced in a **subprocess** so that the +assertion holds against the real module-loading order. An in-process +test could pass even when the eager import sneaks in, because earlier +test-side imports may already have populated ``sys.modules`` for +httpx / urllib. +""" + +from __future__ import annotations + +import subprocess +import sys +import textwrap + + +def _run_in_subprocess(snippet: str) -> tuple[int, str, str]: + """Run ``snippet`` in a fresh Python subprocess and return (rc, stdout, stderr). + + Uses ``sys.executable`` (the interpreter pytest is running under, + which is the venv python when invoked via ``uv run``) so the + subprocess sees the installed SDK on its ``sys.path``. We do **not** + pass ``-S``: that flag disables the venv's ``site.py`` shim and + would render the SDK uninstalled for the subprocess. + """ + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(snippet)], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + return result.returncode, result.stdout, result.stderr + + +def test_root_import_does_not_load_https_libs() -> None: + """``import youdotcom`` must leave httpx and urllib.request off sys.modules. + + Regression guard; failing this means ``youdotcom/__init__.py`` + has re-introduced an eager import path that drags transport modules in + at ``import`` time. + """ + snippet = """ + import sys + + import youdotcom + + # Transport-layer modules must NOT be present after a bare + # ``import youdotcom``. ``urllib.request`` is enough of a marker + # because the offending ``from .sdk import *`` pulls the full + # ``urllib`` subtree transitively. + httpx_loaded = "httpx" in sys.modules + urllib_request_loaded = "urllib.request" in sys.modules + if httpx_loaded or urllib_request_loaded: + print("TRANSPORT_LEAK:", "httpx", httpx_loaded, "urllib.request", urllib_request_loaded) + sys.exit(2) + + sys.exit(0) + """ + rc, stdout, stderr = _run_in_subprocess(snippet) + assert rc == 0, ( + "import youdotcom leaked transport-layer modules.\n" + f"stdout: {stdout!r}\nstderr: {stderr!r}" + ) + + +def test_root_import_exposes_you_class() -> None: + """``from youdotcom import You`` resolves to ``BaseSDK`` subclass. + + The root package's public surface contract is a single class export, + ``You``, plus module-level constants and sub-package access. This + is the import path every existing test in ``tests/`` uses. + """ + snippet = """ + import sys + from youdotcom import You + from youdotcom.basesdk import BaseSDK + if not (isinstance(You, type) and issubclass(You, BaseSDK)): + print("PUBLIC_SURFACE_MISMATCH:", You) + sys.exit(2) + """ + rc, stdout, stderr = _run_in_subprocess(snippet) + assert rc == 0, f"from youdotcom import You failed: {stdout!r} {stderr!r}" + + +def test_root_import_exposes_subpackages() -> None: + """``youdotcom.models``, ``youdotcom.errors`` etc. resolve as sub-package attributes.""" + snippet = """ + import sys + import youdotcom.models # noqa: F401 + import youdotcom.errors # noqa: F401 + import youdotcom.utils # noqa: F401 + import youdotcom.types # noqa: F401 + + missing = [ + name + for name in ("models", "errors", "utils", "types") + # sub-package attribute access must not raise AttributeError + if not hasattr(__import__("youdotcom"), name) + ] + if missing: + print("MISSING_SUBPACKAGES:", missing) + sys.exit(2) + """ + rc, stdout, stderr = _run_in_subprocess(snippet) + assert rc == 0, f"sub-package access failed: {stdout!r} {stderr!r}" diff --git a/uv.lock b/uv.lock index 3ca2536..7ca4845 100644 --- a/uv.lock +++ b/uv.lock @@ -810,7 +810,7 @@ wheels = [ [[package]] name = "youdotcom" -version = "3.0.0" +version = "3.1.2" source = { editable = "." } dependencies = [ { name = "httpcore" },