From dfd4ca632bea94fd9dc569b0921f52e633db0ca8 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Thu, 20 Aug 2026 12:53:38 -0700 Subject: [PATCH 1/2] Python SDK 3.1.2: lazy imports, attribution header, SSE forward-compat, answer safesearch Fixed - Models are usable inside a Temporal Workflow. The package root resolves its public names lazily via PEP 562 `__getattr__`, so `import youdotcom` no longer pulls `httpx` / `urllib.request` into `sys.modules` and a Workflow module can import SDK models without a `workflow.unsafe.imports_passed_through()` work-around. - `ResearchTaskStreamEvent.event` accepts future SSE event names. `Event` uses `OpenEnumMeta` and the field is declared `EventName` (`Union[Event, str]`), which is what it holds at runtime: known names resolve to `Event` members, unknown names stay plain `str` instead of raising `ResponseValidationError`. Added - `X-Client-Info` attribution header on every outbound request, with optional keyword-only `You(app_name=..., app_version=..., app_title=..., app_url=...)` so a caller can identify itself. Values must be printable ASCII excluding `;` (and `/` for `app_name` / `app_version`, which are joined as `/`), validated before any transport is constructed. The leading `sdk` token names the channel, matching the `mcp` token the You.com MCP server emits in the same position. The calling language stays recoverable from `ua=`, which no wrapping integration can override, and the SDK's own name and version from the `User-Agent`. `client=` identifies the caller, not the SDK, and is dropped entirely when `app_name` is unset. That keeps the segment meaningful: an undeclared caller emits `sdk; ua=python/...`, while an integration emits its own `client=/`, so direct SDK use and wrapping integrations are distinguishable without relying on convention. The SDK never emits `X-MCP-Attribution`; that header belongs to the MCP server. - `safesearch` on `You.answer()` / `answer_async()`, matching `search()`. Root namespace narrowing Dropping the eager `from .sdk import *` also stops it leaking its own imports onto `youdotcom`. 24 of the 48 public names the root carried on 3.1.1 were import machinery, not API: `httpx`, `asyncio`, `warnings`, `weakref`, the typing and dataclass helpers, internal helpers (`eventstreaming`, `get_security_from_env`, `unmarshal_json_response`, `remove_suffix`), and the private submodule aliases (still importable directly). Every documented name still resolves from the root and still binds under `from youdotcom import *`. The version dunders stay out of `__all__` so a star import cannot overwrite a consumer package's own `__version__`. See MIGRATION.md "3.1.1 -> 3.1.2". Notes - `sdk.py` resolves the `crawl_timeout` default inside its branch so the common path never forces `models` to load; astroid cannot infer through the lazy root, so the subscript carries a targeted pylint disable. - Verified: mypy clean (82 files), `pylint --enable=E` gate exit 0, pyright clean on the root, 302 tests passing, no OpenAPI drift, and the attribution header accepted by the live API. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 122 +++ MIGRATION.md | 93 ++- README.md | 64 +- USAGE.md | 39 +- docs/models/answerrequestbody.md | 1 + docs/models/event.md | 44 +- docs/models/eventname.md | 52 ++ docs/models/researchtaskstreamevent.md | 2 +- docs/sdks/answer/README.md | 11 +- docs/sdks/you/README.md | 30 + examples/api-example-calls.py | 14 +- pyproject.toml | 2 +- src/youdotcom/__init__.py | 176 ++++- src/youdotcom/_version.py | 2 +- src/youdotcom/basesdk.py | 11 + src/youdotcom/models/__init__.py | 3 + src/youdotcom/models/answerrequestbody.py | 6 +- .../models/researchtaskstreamevent.py | 44 +- src/youdotcom/research_helpers.py | 20 +- src/youdotcom/sdk.py | 116 ++- src/youdotcom/sdkconfiguration.py | 11 + src/youdotcom/utils/__init__.py | 5 + src/youdotcom/utils/attribution.py | 184 +++++ tests/test_answer.py | 27 + tests/test_attribution.py | 726 ++++++++++++++++++ tests/test_live.py | 104 ++- tests/test_param_normalization.py | 6 +- tests/test_researchtaskstreamevent.py | 311 ++++++++ tests/test_root_init.py | 208 +++++ uv.lock | 2 +- 30 files changed, 2373 insertions(+), 63 deletions(-) create mode 100644 docs/models/eventname.md create mode 100644 src/youdotcom/utils/attribution.py create mode 100644 tests/test_attribution.py create mode 100644 tests/test_researchtaskstreamevent.py create mode 100644 tests/test_root_init.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c180b91..cb52fde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,128 @@ 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-20 + +Sister release track to the extraction-parameter rollout +(3.1.0 + 3.1.1). Every documented surface is backward-compatible; see +"Root namespace narrowing" below for the one exception, which affects +only names that were never part of the public API. + +The top-level root surface now resolves lazily via PEP 562 +``__getattr__``. `You`, `SDKConfiguration`, `BaseSDK`, `RetryConfig`, +`BackoffStrategy`, the `HttpClient` protocols, the logger and hook +helpers, and the `models` / `errors` / `utils` / `types` sub-packages +all still resolve from the package root, and all still bind under +`from youdotcom import *` — but each is imported on first access, so +``import youdotcom`` no longer pulls transport-layer modules into +``sys.modules``. + +### Root namespace narrowing + +Replacing the eager `from .sdk import *` / `from .sdkconfiguration +import *` also stops those statements from leaking their own imports +onto `youdotcom`. On 3.1.1 the package root carried 48 public names; +24 of them were import machinery rather than API, and are gone: + +- stdlib and third-party modules the SDK imports internally: `httpx`, + `asyncio`, `warnings`, `weakref` +- typing and dataclass helpers: `Any`, `Callable`, `Dict`, `Iterable`, + `List`, `Mapping`, `Optional`, `Tuple`, `Union`, `cast`, + `dataclass`, `field` +- internal plumbing: `eventstreaming`, `get_security_from_env`, + `unmarshal_json_response`, `remove_suffix` +- private submodule aliases: `youdotcom.sdk`, `youdotcom.basesdk`, + `youdotcom.httpclient`, `youdotcom.sdkconfiguration` (still + importable directly, e.g. `import youdotcom.sdk`) + +None were documented, exported deliberately, or referenced by any +example. The narrowing is intentional: re-exporting them is what +dragged `httpx` and `urllib.request` into every `import youdotcom`. +If you were relying on one, import it from its own module — +`from youdotcom.utils import eventstreaming`. See +`MIGRATION.md` ("3.1.1 → 3.1.2") for the before/after. + +Going the other way, `BackoffStrategy` now resolves from the root +(`from youdotcom import BackoffStrategy`) alongside `RetryConfig`, +which it configures; on 3.1.1 only `RetryConfig` did. + +`__version__`, `__title__`, `__user_agent__`, and +`__openapi_doc_version__` remain reachable as attributes +(`youdotcom.__version__`) and, as on 3.1.1, are deliberately **not** +bound by `from youdotcom import *`, so a star import cannot overwrite +a consumer package's own `__version__`. + +### Fixed + +- **Models are usable inside a Temporal Workflow.** + `youdotcom/__init__.py` no longer eagerly pulls transport-layer + modules (including `httpx` and `urllib.request`) into + ``sys.modules``, 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.** + `Event` now uses `OpenEnumMeta` 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 unmarshal as plain `str` values that compare + equal to their 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. Serialization produces no warnings. + Coverage in `tests/test_researchtaskstreamevent.py`, including a + regression test that drives the real `stream_research_task` SSE + decode path with unknown event names. + + The field is declared `EventName` (a new public alias for + `Union[Event, str]`, exported from `youdotcom.models`) because that + is what it holds at runtime. **Typed callers may see a new type + error:** `evt.event.value` no longer type-checks, since the value is + a plain `str` for any event name this SDK version does not + enumerate. That error is the bug surfacing rather than a new + restriction — the same code raises `AttributeError` at runtime the + first time the server emits a new name. Guard with + `isinstance(evt.event, Event)` before using the enum API, or compare + against raw strings, which needs no guard. + + Direct coercion also stops validating: `Event("bogus")` now returns the + string rather than raising `ValueError`. That is inherent to an open enum + and applies only to `Event`, whose values the server may extend; the + request-side enums (`SafeSearch`, `Country`, `Language`, ...) stay closed + and still raise. + +### Added + +- **Attribution header `X-Client-Info` on every outbound request.** + New optional `You(app_name=..., app_version=..., app_title=..., + app_url=...)` constructor args identify the calling application. They are + keyword-only, so later attribution args can be added without a breaking + change; the existing positional parameters are untouched. Wire format: + + sdk[; client=[/]][; title=][; url=<url>]; ua=python/<V> httpx/<V> + + so the analytics layer can distinguish SDK traffic from other + sources. The leading `sdk` token names the channel, matching the `mcp` and + `skill` tokens emitted elsewhere; the calling language stays recoverable + from `ua=`, and the SDK's own version from the `User-Agent`. `client=` + identifies the *caller* and is dropped entirely when `app_name` is unset, + so an undeclared caller emits `sdk; ua=python/… httpx/…`. All four values + must be printable ASCII excluding `;`, with `app_name` / `app_version` also + excluding `/`; invalid values raise `ValueError` at construction time, as + does passing `app_version` without `app_name`. +- **`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/MIGRATION.md b/MIGRATION.md index 8d623ea..39fb9e5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -297,6 +297,68 @@ with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: # str(deprecations[0].message) == "livecrawl is deprecated; use extraction instead" ``` +## 3.1.1 → 3.1.2 + +> **Additive release.** No parameter is renamed, removed, or changed in +> meaning. Two changes can require an edit, and only in narrow cases. + +### Action required + +| Change | Who is affected | What to do | +|--------|-----------------|------------| +| Import machinery no longer re-exported from the package root | Anyone importing a stdlib/typing name or internal helper *from* `youdotcom` | Import it from its real home. See [Root namespace narrowing](#root-namespace-narrowing-1) | +| `ResearchTaskStreamEvent.event` is typed `Union[Event, str]` | Type-checked code calling `evt.event.value` | Guard with `isinstance(evt.event, Event)`. See [SSE event names](#sse-event-names) | + +### Root namespace narrowing + +`youdotcom/__init__.py` no longer does `from .sdk import *`, so the names +those statements pulled in transitively are no longer attributes of +`youdotcom`. This is what makes `import youdotcom` transport-free inside a +Temporal Workflow sandbox. + +```python +# Before (3.1.1): worked by accident. +from youdotcom import httpx, Optional, eventstreaming + +# After (3.1.2): ImportError. Import from the real module. +import httpx +from typing import Optional +from youdotcom.utils import eventstreaming +``` + +Everything documented still resolves from the root, including under +`from youdotcom import *`: + +```python +from youdotcom import You, SDKConfiguration, RetryConfig, BackoffStrategy +from youdotcom import models, errors, utils, types +import youdotcom.sdk # private submodules still import directly +``` + +### SSE event names + +The `event` field on `ResearchTaskStreamEvent` accepts event names this SDK +version does not enumerate, so an added server-side event no longer raises +`ResponseValidationError`. Known names still resolve to `Event` members; +unknown names arrive as plain `str`. The declared type now says so, which +means an unguarded `.value` becomes a type error: + +```python +# Type error on 3.1.2 -- and an AttributeError at runtime, on 3.1.1 too, +# the first time the server emits a name this SDK doesn't know. +print(evt.event.value) + +# Guarded: correct on both versions. +if isinstance(evt.event, Event): + print(evt.event.value) +else: + print(evt.event) + +# Comparing against raw strings needs no guard -- Event members are `str`. +if evt.event == "completed": + ... +``` + ## 2.4.0 → 2.5.0 ### New `frontier` Research Effort Tier @@ -411,15 +473,40 @@ task = research_background(you, input="...", research_effort=ResearchEffort.DEEP detail = poll_research_task(you, task_id=task.task_id) # Option 3: Stream SSE events with a tolerant decoder -# (recommended over you.stream_research_task for real tasks, since -# the server emits intermediate event types not in the strict enum) +# (see the note below on choosing between this and +# you.stream_research_task) for event in stream_research(you, task_id=task.task_id): print(event.event, event.data) if event.event in ("response.done", "complete", "completed"): break + if event.event in ("error", "failed", "cancelled"): + break ``` -> **Note on streaming:** The generated `you.stream_research_task()` method uses a strict pydantic decoder that validates event names against a fixed `Event` enum. The server emits intermediate workflow events (e.g. `response.created`, `response.starting`, `response.output_item.added`) that are not in this enum, which causes `ResponseValidationError` on the first intermediate event. The `stream_research()` helper uses a tolerant decoder that surfaces unknown event names as raw dicts instead of crashing. For real research tasks, prefer `stream_research()`. +> **Note on streaming (as written for 2.5.0):** The generated +> `you.stream_research_task()` method uses a strict pydantic decoder that +> validates event names against a fixed `Event` enum. The server emits +> intermediate workflow events (e.g. `response.created`, `response.starting`, +> `response.output_item.added`) that are not in this enum, which causes +> `ResponseValidationError` on the first intermediate event. The +> `stream_research()` helper uses a tolerant decoder that surfaces unknown +> event names instead of crashing. For real research tasks, prefer +> `stream_research()`. (It yields `RawStreamEvent` objects, not raw dicts as +> originally written here; `.event` is a `str` and `.data` is the parsed JSON +> payload.) +> +> **Updated in 3.1.2 — the `ResponseValidationError` half of this no longer +> applies.** `Event` is now an open enum and the field is typed +> `EventName` (`Union[Event, str]`), so `you.stream_research_task()` decodes +> unenumerated event names as plain `str` instead of raising. The three event +> names above are covered by a regression test. See "3.1.1 → 3.1.2" above. +> +> `stream_research()` still differs in which frames it surfaces: +> `stream_research_task()` drops data-less frames (a bare `event: ping` +> heartbeat, or one carrying only `id:`/`retry:`) and requires every frame to +> carry an `id` and a JSON-object `data`, whereas `stream_research()` yields +> them and tolerates a `data` payload that is not valid JSON. If you only +> needed unknown-event tolerance, either method now works. ### Polling and Timeout Guidance diff --git a/README.md b/README.md index 9413a0c..3d792c8 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"], ) @@ -243,14 +244,21 @@ from youdotcom.research_helpers import stream_research for evt in stream_research(you, task_id=task.task_id): print(evt.event, evt.data) - if evt.event in ("response.done", "completed", "error", "failed", "cancelled"): + if evt.event in ("response.done", "complete", "completed", "error", "failed", "cancelled"): break ``` -`stream_research()` tolerates event names outside the documented set, yielding -them as raw dicts. Prefer it over `you.stream_research_task()`, which validates -strictly and will raise on an unrecognized event. Pass `from_id` to resume a -stream after a disconnect. +`stream_research()` yields `RawStreamEvent` objects, whose `.event` is the raw +event name as a `str` and whose `.data` is the parsed JSON payload (or the raw +string when a frame is not valid JSON, which it tolerates). Since 3.1.2 both +methods tolerate event names outside the documented set — `you.stream_research_task()` +surfaces an unenumerated name as a plain `str` rather than raising — so the +remaining difference is which frames reach you: `stream_research()` yields +data-less frames (a bare `event: ping` heartbeat, or one carrying only +`id:`/`retry:`), whereas `you.stream_research_task()` silently drops them and +requires every frame to carry an `id` and a JSON-object `data`. Prefer +`stream_research()` when you need every frame, including keep-alives. Pass +`from_id` to resume a stream after a disconnect. Each helper has an `_async` twin: `research_and_wait_async`, `research_background_async`, `poll_research_task_async`, `stream_research_async`. @@ -356,6 +364,52 @@ 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: + +``` +sdk[; client=<name>[/<version>]][; title=<title>][; url=<url>]; ua=python/<V> httpx/<V> +``` + +The leading `sdk` token names the channel, matching the `mcp` and `skill` +tokens other You.com surfaces emit. The calling language stays recoverable +from `ua=` (`python/…` here, `node/…` from the TypeScript SDK), and the SDK's +own version travels in the `User-Agent` (`youdotcom-python-sdk/<version>`). + +Identify your application with `app_name` / `app_version`, which populate the +`client=` segment, and optionally `app_title` / `app_url`: + +```python +import os +from youdotcom import You + +with You( + api_key_auth=os.getenv("YDC_API_KEY"), + app_name="acme-bot", + app_version="2.4.0", + app_title="Acme Bot", + app_url="https://acme.example", + timeout_ms=60_000, +) as you: + res = you.search(query="...") +``` + +All four are optional and keyword-only; existing call sites are unaffected. +When `app_name` is +unset the `client=` segment is dropped entirely, so a request from an +undeclared caller is simply `sdk; ua=python/… httpx/…`. + +Values must be printable ASCII excluding `;`, and `app_name` / `app_version` +additionally exclude `/` since they are joined as `<name>/<version>`. Passing +`app_version` without `app_name` is an error, since a bare version has nowhere +to go. Invalid values raise `ValueError` at construction time. + +The MCP-specific `X-MCP-Attribution` header is never set by the SDK — it is +assembled on the MCP server, which is the only layer that can populate its +`keyless` / `payment` / `ip` flags accurately. + ### Servers `search` and `contents` go to `https://ydc-index.io`. Everything else goes to diff --git a/USAGE.md b/USAGE.md index 34efedd..230d730 100644 --- a/USAGE.md +++ b/USAGE.md @@ -91,4 +91,41 @@ 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_name="acme-bot", + app_version="2.4.0", + app_title="Acme Bot", + app_url="https://acme.example", + 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: + +``` +sdk; client=acme-bot/2.4.0; title=Acme Bot; url=https://acme.example; ua=python/<V> httpx/<V> +``` + +`app_name`, `app_version`, `app_title` and `app_url` are all optional and +keyword-only. When omitted, those segments are dropped entirely, so an +undeclared caller sends just `sdk; ua=python/<V> httpx/<V>`. Values must be printable ASCII excluding `;`, and `app_name` / `app_version` +additionally exclude `/` since they are joined as `<name>/<version>`. Passing +`app_version` without `app_name` is also an error. Invalid +values raise `ValueError` at construction time. +<!-- 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..a43944c 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. This allows you to decide whether to return NSFW content or not. | | `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/models/event.md b/docs/models/event.md index 67efe4c..51db681 100644 --- a/docs/models/event.md +++ b/docs/models/event.md @@ -21,4 +21,46 @@ value = Event.CONNECTED | `COMPLETED` | completed | | `ERROR` | error | | `FAILED` | failed | -| `CANCELLED` | cancelled | \ No newline at end of file +| `CANCELLED` | cancelled | + +## Open enum + +This list is **not** exhaustive. `Event` is an open enum: an event name the +SDK does not yet enumerate unmarshals as a plain `str` rather than raising +`ResponseValidationError`, so a server-side event addition does not break +existing clients. Fields typed [`EventName`](../models/eventname.md) hold +either an `Event` member (known name) or a `str` (unknown name). + +Guard before reaching for the enum API: + +```python +from youdotcom.models import Event, ResearchTaskStreamEvent + +known = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "completed", "data": {}} +) +unknown = ResearchTaskStreamEvent.model_validate( + {"id": "2", "event": "some.future.event", "data": {}} +) + +for evt in (known, unknown): + if isinstance(evt.event, Event): + print("known:", evt.event.value) + else: + print("unknown:", evt.event) +# known: completed +# unknown: some.future.event +``` + +Equality against a raw string works either way, because `Event` members are +`str` subclasses — so this needs no guard: + +```python +from youdotcom.models import ResearchTaskStreamEvent + +evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "completed", "data": {}} +) +print(evt.event == "completed") +# True +``` diff --git a/docs/models/eventname.md b/docs/models/eventname.md new file mode 100644 index 0000000..4b44fd6 --- /dev/null +++ b/docs/models/eventname.md @@ -0,0 +1,52 @@ +# EventName + +The declared type of `ResearchTaskStreamEvent.event`: + +```python +from typing import Union + +from youdotcom.models import Event + +EventName = Union[Event, str] +``` + +A known SSE event name resolves to the corresponding +[`Event`](../models/event.md) member. An unknown name — one the installed SDK +version does not enumerate — stays a plain `str`, so a server-side event +addition unmarshals cleanly instead of raising `ResponseValidationError`. + +## Example Usage + +```python +from youdotcom.models import Event, ResearchTaskStreamEvent + +# Two frames as they arrive off the SSE stream: one name this SDK version +# enumerates, one it does not. +stream = [ + ResearchTaskStreamEvent.model_validate({"id": "1", "event": "connected", "data": {}}), + ResearchTaskStreamEvent.model_validate({"id": "2", "event": "checkpoint", "data": {}}), +] + +for evt in stream: + if isinstance(evt.event, Event): + # Known name: full enum API available. + print(evt.event.value) + else: + # Unknown name: forward-compatible passthrough. + print(f"unrecognized event: {evt.event}") +# connected +# unrecognized event: checkpoint +``` + +Callers that only compare against raw strings need no guard, because `Event` +members are `str` subclasses: + +```python +from youdotcom.models import ResearchTaskStreamEvent + +evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "completed", "data": {}} +) +print(evt.event == "completed") +# True +``` diff --git a/docs/models/researchtaskstreamevent.md b/docs/models/researchtaskstreamevent.md index 0fe2392..1871546 100644 --- a/docs/models/researchtaskstreamevent.md +++ b/docs/models/researchtaskstreamevent.md @@ -8,5 +8,5 @@ A server-sent event for a background research task stream. | Field | Type | Required | Description | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | *str* | :heavy_check_mark: | Sequence number of the SSE event. | -| `event` | [models.Event](../models/event.md) | :heavy_check_mark: | 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` | [models.EventName](../models/eventname.md) | :heavy_check_mark: | 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 unmarshal as plain `str` rather than an `Event` member; guard with `isinstance(evt.event, Event)` before using the enum API. | | `data` | [models.ResearchTaskStreamEventData](../models/researchtaskstreameventdata.md) | :heavy_check_mark: | The event payload. Structure varies by event type. Common fields include type, task_id, status, data (event-specific), error, and sequence. | \ No newline at end of file diff --git a/docs/sdks/answer/README.md b/docs/sdks/answer/README.md index 6b7d022..6cb7928 100644 --- a/docs/sdks/answer/README.md +++ b/docs/sdks/answer/README.md @@ -2,7 +2,7 @@ ## 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. +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, domain, and explicit-content controls. Called as a direct method on the `You` client: `you.answer(query=...)`. @@ -12,7 +12,7 @@ Called as a direct method on the `You` client: `you.answer(query=...)`. ## 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. +Returns a synthesized natural-language answer with citations and the web results used to generate it. Provide a `query` and optional freshness, locale, domain, and explicit-content controls. ### Example Usage @@ -23,9 +23,13 @@ from youdotcom import You with You( api_key_auth=os.getenv("YDC_API_KEY"), + timeout_ms=60_000, ) as you: - res = you.answer(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?", + safesearch="strict", + ) # Handle response print(res.answer) @@ -42,6 +46,7 @@ with You( | `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. | +| `safesearch` | *Optional[str]* | :heavy_minus_sign: | Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not. | | `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..adbba12 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -42,6 +42,36 @@ with You( print(res) ``` +### Parameters + +| Parameter | Type | Required | Description | Example | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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. | What are the tradeoffs of vector vs. keyword search? | +| `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. This allows you to decide whether to return NSFW content or not. | | +| `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. | http://localhost:8080 | + +### 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 | \*/\* | + ## 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/examples/api-example-calls.py b/examples/api-example-calls.py index 419c40f..d90be7d 100755 --- a/examples/api-example-calls.py +++ b/examples/api-example-calls.py @@ -254,10 +254,16 @@ def research_stream_example(): Research API with streaming: submit in background mode, then stream real-time SSE events with the tolerant stream_research helper. - The tolerant helper surfaces undocumented intermediate event types - (e.g. research.searching, response.created) as raw dicts instead of - crashing on pydantic validation. Recommended over the generated - you.stream_research_task() for real tasks. + The helper yields RawStreamEvent objects, so undocumented intermediate + event types (e.g. research.searching, response.created) arrive with + .event as a plain str and .data as the parsed JSON payload. + + Since 3.1.2 you.stream_research_task() also tolerates unenumerated event + names, so it no longer raises on them either. This helper still differs in + which frames it surfaces: stream_research_task() drops data-less frames + (a bare `event: ping` heartbeat, or one carrying only id:/retry:) and + requires every frame to have an id and a JSON-object data, whereas this + one yields them and accepts a data payload that is not valid JSON. """ from youdotcom.research_helpers import research_background, stream_research 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..d9f45a2 100644 --- a/src/youdotcom/__init__.py +++ b/src/youdotcom/__init__.py @@ -1,15 +1,179 @@ +"""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 (via ``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 + +Backward-compatibility re-exports (previously available via +``from .sdk import *`` / ``from .sdkconfiguration import *``): + +- ``SDKConfiguration``, ``SERVERS`` — from ``.sdkconfiguration`` +- ``BaseSDK`` — from ``.basesdk`` +- ``HttpClient``, ``AsyncHttpClient``, ``ClientOwner``, ``close_clients`` — + from ``.httpclient`` +- ``Logger``, ``get_default_logger`` — from ``.utils.logger`` +- ``RetryConfig``, ``BackoffStrategy`` — from ``.utils.retries`` +- ``HookContext``, ``SDKHooks`` — from ``._hooks`` +- ``ContentsShim``, ``SearchShim`` — from ``._shims`` +- ``OptionalNullable``, ``UNSET`` — from ``.types`` + +Sub-packages accessed as ``youdotcom.<name>.X``: + +- ``models``, ``errors``, ``utils``, ``types`` + +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: + # The sub-packages are named in ``__all__`` (see the comment there), and a + # type checker resolves ``__all__`` entries statically — it does not know + # about the PEP 562 ``__getattr__`` that binds them at runtime. Without + # these, pyright reports ``reportUnsupportedDunderAll`` for each one, and + # this package ships ``py.typed``, so that lands in consumers' editors. + # ``TYPE_CHECKING`` is ``False`` at runtime, so this costs no import. + from . import errors, models, types, utils + from .sdk import You + from .sdkconfiguration import SDKConfiguration, SERVERS + from .basesdk import BaseSDK + from .httpclient import AsyncHttpClient, ClientOwner, HttpClient, close_clients + from .utils.logger import Logger, get_default_logger + from .utils.retries import BackoffStrategy, RetryConfig + from ._hooks import HookContext, SDKHooks + from ._shims import ContentsShim, SearchShim + from .types import OptionalNullable, UNSET + + +__all__ = [ + "OPENAPI_DOC_VERSION", + "USER_AGENT", + "VERSION", + "You", + # NOTE: the ``__version__`` / ``__title__`` / ``__user_agent__`` / + # ``__openapi_doc_version__`` dunders are deliberately NOT listed here. + # Without ``__all__``, ``from youdotcom import *`` skips underscore names; + # naming them would bind them, so a consumer package that sets its own + # ``__version__`` and then star-imports the SDK would silently report the + # SDK's version as its own. They stay reachable as plain attributes + # (``youdotcom.__version__``, ``from youdotcom import __version__``), + # exactly as they were through 3.1.1. + # Backward-compatibility re-exports + "SDKConfiguration", + "SERVERS", + "BaseSDK", + "HttpClient", + "AsyncHttpClient", + "ClientOwner", + "close_clients", + "Logger", + "get_default_logger", + "RetryConfig", + "BackoffStrategy", + "HookContext", + "SDKHooks", + "ContentsShim", + "SearchShim", + "OptionalNullable", + "UNSET", + # Sub-packages. Listed here (not just in ``_sub_packages``) because + # ``from youdotcom import *`` binds exactly the names in ``__all__``; + # omitting them would silently stop `import *` followed by + # ``models.SearchRequestBody(...)`` from resolving, which the eager + # ``from .sdk import *`` surface supported through 3.1.1. + "errors", + "models", + "types", + "utils", +] + +# 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 public attributes that require importing a submodule +# on first access. Each entry maps a public name to its source module. +_dynamic_imports: dict[str, str] = { + "You": ".sdk", + "SDKConfiguration": ".sdkconfiguration", + "SERVERS": ".sdkconfiguration", + "BaseSDK": ".basesdk", + "HttpClient": ".httpclient", + "AsyncHttpClient": ".httpclient", + "ClientOwner": ".httpclient", + "close_clients": ".httpclient", + "Logger": ".utils.logger", + "get_default_logger": ".utils.logger", + "RetryConfig": ".utils.retries", + "BackoffStrategy": ".utils.retries", + "HookContext": "._hooks", + "SDKHooks": "._hooks", + "ContentsShim": "._shims", + "SearchShim": "._shims", + "OptionalNullable": ".types", + "UNSET": ".types", +} + + +# 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] = [ + "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__) + # Version dunders are deliberately excluded from ``__all__`` (so a + # star-import can't clobber a consumer's own ``__version__``), but + # they are real module attributes and should remain discoverable + # via ``dir()`` / IDE autocomplete. + | {"__version__", "__title__", "__user_agent__", "__openapi_doc_version__"} + ) 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..7230aed 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,16 @@ 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_name=self.sdk_configuration.app_name, + app_version=self.sdk_configuration.app_version, + 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/__init__.py b/src/youdotcom/models/__init__.py index c126f24..796a283 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -90,6 +90,7 @@ ) from .researchtaskstreamevent import ( Event, + EventName, ResearchTaskStreamEvent, ResearchTaskStreamEventData, ResearchTaskStreamEventDataTypedDict, @@ -143,6 +144,7 @@ "ContentsTypedDict", "Country", "Event", + "EventName", "Extraction", "ExtractionFormat", "ExtractionFullPage", @@ -304,6 +306,7 @@ "Source": ".researchresponse", "SourceTypedDict": ".researchresponse", "Event": ".researchtaskstreamevent", + "EventName": ".researchtaskstreamevent", "Extraction": ".extraction", "ExtractionFormat": ".extraction", "ExtractionFullPage": ".extraction", diff --git a/src/youdotcom/models/answerrequestbody.py b/src/youdotcom/models/answerrequestbody.py index 90a16c6..fd57179 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. This allows you to decide whether to return NSFW content or not.""" + 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..692359c 100644 --- a/src/youdotcom/models/researchtaskstreamevent.py +++ b/src/youdotcom/models/researchtaskstreamevent.py @@ -1,15 +1,28 @@ - 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, +) +from youdotcom.utils.enums import OpenEnumMeta -class Event(str, Enum): +class Event(str, Enum, metaclass=OpenEnumMeta): 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. Unknown event names + are accepted as plain strings so that a future server-side event + addition does not break unmarshal. Equality checks against a known + name (``evt.event == "connected"``) keep working because ``Event`` + members are ``str`` subclasses. Use ``isinstance(evt.event, Event)`` + to distinguish known from unknown values. """ CONNECTED = "connected" @@ -21,6 +34,11 @@ class Event(str, Enum): CANCELLED = "cancelled" +# Public alias so callers reading IDE/help see that any string is +# accepted on the wire, not just the enum members. +EventName = Union[Event, str] + + 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 +110,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 +123,20 @@ class ResearchTaskStreamEvent(BaseModel): id: str r"""Sequence number of the SSE event.""" - event: Event + 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. + + Declared as ``EventName`` (``Union[Event, str]``) because that is what + the field actually holds at runtime: a known name resolves to the + ``Event`` member, and an unknown name stays a plain ``str`` 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 ``Event`` members are ``str`` subclasses. Callers that want the + enum API (``evt.event.value``) must guard with + ``isinstance(evt.event, Event)`` first — that check returns ``False`` + for unknown names, and the declared union is what makes the guard + meaningful to a type checker. """ data: ResearchTaskStreamEventData diff --git a/src/youdotcom/research_helpers.py b/src/youdotcom/research_helpers.py index e56bfbf..8a038da 100644 --- a/src/youdotcom/research_helpers.py +++ b/src/youdotcom/research_helpers.py @@ -13,10 +13,14 @@ with ``background=True``, then stream SSE events until a terminal event arrives, and fetch the final ``TaskDetail``. - ``stream_research`` / ``stream_research_async``: Iterate the SSE event - stream with a tolerant decoder that surfaces non-typed event names - (``research.searching``, etc.) as raw dicts instead of crashing on - validation. Use this when the server may emit event types outside the - documented enum. + stream with a tolerant decoder, yielding ``RawStreamEvent`` objects whose + ``event`` is the raw name as a ``str`` and whose ``data`` is the parsed + JSON payload (or the raw string when a frame is not valid JSON). Since + 3.1.2 ``stream_research_task`` also tolerates unenumerated event names, so + the remaining difference is frame coverage: these helpers pass + ``data_required=False``, so they yield data-less frames (a bare + ``event: ping`` heartbeat, or one carrying only ``id:``/``retry:``) that + ``stream_research_task`` silently drops. """ from __future__ import annotations @@ -95,8 +99,10 @@ def _decode_raw_event(raw_json: str) -> RawStreamEvent: """Tolerant SSE decoder used by ``stream_research``. Accepts any JSON object regardless of whether ``event`` matches the - documented enum, so unknown workflow events pass through instead of - failing pydantic validation. + documented enum, and unlike the pydantic path also accepts a ``data`` + payload that is not a JSON object at all. Since 3.1.2 the pydantic model + tolerates unenumerated event names too (``Event`` is an open enum), so this + decoder's remaining advantage is shape tolerance, not name tolerance. """ parsed = json.loads(raw_json) if not isinstance(parsed, dict): @@ -616,7 +622,7 @@ def _open_raw_stream( Mirrors the request setup used by ``client.stream_research_task`` but wires :func:`_decode_raw_event` into the ``EventStream`` instead of the - strict pydantic ``ResearchTaskStreamEvent`` decoder. + pydantic ``ResearchTaskStreamEvent`` decoder. """ kwargs = _stream_build_kwargs( client, task_id, http_headers=http_headers, from_id=from_id, diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 56cdd1e..3154c5d 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -25,6 +25,7 @@ from youdotcom._shims import ContentsShim, SearchShim from youdotcom.types import OptionalNullable, UNSET from youdotcom.utils import eventstreaming, get_security_from_env +from youdotcom.utils.attribution import validate_attribution_arg from youdotcom.utils.unmarshal_json_response import unmarshal_json_response @@ -117,17 +118,27 @@ def _build_search_request( extraction_model is not None and extraction_model.extraction_mode is models.ExtractionMode.HIGHLIGHTS ) - if ( - strip_crawl_timeout - and crawl_timeout is not None - and crawl_timeout - != models.SearchRequestBody.model_fields["crawl_timeout"].default - ): - warnings.warn( - "crawl_timeout is ignored when extraction_mode == 'highlights'", - UserWarning, - stacklevel=4, - ) + if strip_crawl_timeout and crawl_timeout is not None: + # Resolved inside the branch so the common path never forces + # ``models.SearchRequestBody`` to load (the lazy package root is what + # keeps ``import youdotcom`` transport-free -- see DX-776). + # + # astroid cannot infer attributes through the PEP 562 ``__getattr__`` + # in ``models/__init__.py``, so it types ``model_fields`` as an + # unsubscriptable ``Any`` and the CI ``pylint --enable=E`` gate fails + # on the subscript. mypy resolves it correctly via the + # ``TYPE_CHECKING`` block, so this is a pylint-inference limitation, + # not a real typing gap. + # pylint: disable-next=unsubscriptable-object + default_crawl_timeout = models.SearchRequestBody.model_fields[ + "crawl_timeout" + ].default + if crawl_timeout != default_crawl_timeout: + warnings.warn( + "crawl_timeout is ignored when extraction_mode == 'highlights'", + UserWarning, + stacklevel=4, + ) body: dict[str, Any] = dict( query=query, @@ -197,6 +208,17 @@ def __init__( retry_config: OptionalNullable[RetryConfig] = UNSET, timeout_ms: Optional[int] = None, debug_logger: Optional[Logger] = None, + # Keyword-only: these four are new in 3.1.2, so nothing can be relying + # on their position, and pinning that now means later attribution args + # can be added or reordered without touching any caller. Matches the + # generated method surface (``answer``, ``search``, ... are all + # keyword-only). The nine parameters above stay positional-or-keyword + # because ``You("api-key")`` is a shape released callers may rely on. + *, + app_name: Optional[str] = None, + app_version: Optional[str] = None, + app_title: Optional[str] = None, + app_url: Optional[str] = None, ) -> None: r"""Instantiates the SDK configuring it with the provided parameters. @@ -208,7 +230,53 @@ 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_name: Optional name of the application or integration calling + the SDK, emitted as the ``client=`` segment of ``X-Client-Info``. + Must be printable ASCII (excluding ``;`` and ``/``). Defaults to + ``None`` → segment dropped. + :param app_version: Optional version paired with ``app_name`` as + ``client=<name>/<version>``. Requires ``app_name``: passing a + version without one raises ``ValueError``, since a bare version + has nowhere to go in the header. Must be printable ASCII + (excluding ``;`` and ``/``). + :param app_title: Optional caller-identity title for the ``X-Client-Info`` + attribution header. Must be printable ASCII (excluding ``;``). + Defaults to ``None`` → segment dropped. + :param app_url: Optional caller-identity URL for the ``X-Client-Info`` + attribution header. Must be printable ASCII (excluding ``;``). + Defaults to ``None`` → segment dropped. + :raises ValueError: If any attribution argument contains non-ASCII + characters, control characters, or a delimiter (``;`` for all of + them, plus ``/`` for ``app_name`` / ``app_version``), or if + ``app_version`` is given without ``app_name``. """ + # Validated before any client is constructed. These checks depend on + # nothing else in ``__init__``, and raising after the internal + # ``httpx.Client`` / ``httpx.AsyncClient`` exist would abandon two + # objects the caller never received a handle to and so can never + # close. Today those clients hold no socket until their first request, + # so nothing actually leaks -- this keeps it that way if client + # construction ever starts acquiring a real resource. + # Truthiness, not ``is not None``: the header builder drops the + # segment for any falsy ``app_name``, so ``app_name=""`` with a version + # -- the shape ``os.getenv("APP_NAME", "")`` produces -- would + # otherwise pass this guard and then silently emit no ``client=`` at + # all, losing both values with no error. + if app_version and not app_name: + raise ValueError( + "app_version requires app_name; the attribution header emits " + "them together as client=<name>/<version>, so a version with " + "no name has nowhere to go." + ) + if app_name is not None: + validate_attribution_arg("app_name", app_name, forbidden=";/") + if app_version is not None: + validate_attribution_arg("app_version", app_version, forbidden=";/") + if app_title is not None: + validate_attribution_arg("app_title", app_title) + if app_url is not None: + validate_attribution_arg("app_url", app_url) + client_supplied = True if client is None: client = httpx.Client(follow_redirects=True) @@ -276,6 +344,10 @@ def _resolve_security() -> models.Security: retry_config=retry_config, timeout_ms=timeout_ms, debug_logger=debug_logger, + app_name=app_name, + app_version=app_version, + app_title=app_title, + app_url=app_url, ), parent_ref=self, ) @@ -376,6 +448,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 +459,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 +473,7 @@ 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: ``"strict"``, ``"moderate"``, or ``"off"``. :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 +501,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 +599,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 +610,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 +624,7 @@ 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: ``"strict"``, ``"moderate"``, or ``"off"``. :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 +652,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..6a4e29e 100644 --- a/src/youdotcom/sdkconfiguration.py +++ b/src/youdotcom/sdkconfiguration.py @@ -35,6 +35,17 @@ 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. All four default + # to ``None`` so existing callers (and every existing test) keep + # working without any change. ``app_name`` / ``app_version`` become the + # ``client=`` segment, which is omitted entirely when ``app_name`` is + # unset. + app_name: Optional[str] = None + app_version: Optional[str] = None + 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..d2fe0dd 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, validate_attribution_arg from .annotations import get_discriminator from .datetimes import parse_datetime, parse_duration from .enums import OpenEnumMeta @@ -63,6 +64,8 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T: __all__ = [ "BackoffStrategy", + "build_client_info_header", + "validate_attribution_arg", "FieldMetadata", "find_metadata", "FormMetadata", @@ -117,6 +120,8 @@ async def run_sync_in_thread(func: Callable[..., _T], *args) -> _T: _dynamic_imports: dict[str, str] = { "BackoffStrategy": ".retries", + "build_client_info_header": ".attribution", + "validate_attribution_arg": ".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..0a1bc0c --- /dev/null +++ b/src/youdotcom/utils/attribution.py @@ -0,0 +1,184 @@ +"""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 ``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 transport +imports: ``httpx`` is pulled in lazily at the top of the function body, +so ``import youdotcom`` does not regress because of this module. +""" + +from __future__ import annotations + +import sys +from typing import Optional + + +# Leading literal that identifies the traffic source, matching the tokens the +# MCP server (`mcp`) and the you-research skill (`skill`) emit. It names the +# *channel*, not the language: `sdk` covers every You.com SDK, and the calling +# language stays recoverable from the `ua=` segment (`python/...` vs `node/...`) +# plus the `User-Agent` (`youdotcom-python-sdk/<version>`). A single lowercase +# word also parses under the analytics recipe as written, which a hyphenated +# token does not. +_SOURCE_TOKEN = "sdk" + + +def validate_attribution_arg( + name: str, value: str, *, forbidden: str = ";" +) -> None: + """Validate an attribution header argument. + + Allows printable ASCII (``\\x20``–``\\x7e``) except the characters in + *forbidden*. Rejects non-ASCII, control characters, and delimiter + characters to prevent segment forgery, header injection, and encoding + errors. + + Args: + name: Parameter name for error messages (e.g. ``"app_title"``). + value: Value to validate. + forbidden: Extra delimiter characters to reject, *in addition to* + ``;``, which is always rejected because it separates segments. + ``app_name`` / ``app_version`` pass ``"/"`` here, because the + ``client=<name>/<version>`` value is split on ``/`` downstream, + so a ``/`` inside either half silently corrupts both. + + Raises: + ValueError: If *value* contains characters outside printable + ASCII, ``;``, or any character in *forbidden*. + """ + if not isinstance(value, str): + raise ValueError( + f"{name} must be a str; got {type(value).__name__}. " + "Every attribution value is interpolated into the header verbatim, " + "so a non-str would be rendered via repr() and corrupt the segment." + ) + if value != value.strip(): + # Two failures in one: a whitespace-only value is treated as absent by + # the falsy gates downstream, so it ships an empty-looking segment + # (``client= /1.0``) -- the silent loss the app_name/app_version pairing + # guard exists to prevent. And a padded value (``" acme "``) becomes a + # distinct analytics key that never groups with the unpadded rows. + # Reject rather than silently strip, so the caller sees the mistake. + raise ValueError( + f"{name} must not have leading or trailing whitespace; " + f"got {value!r}" + ) + reasons = { + ";": "the segment delimiter", + "/": "the client=<name>/<version> delimiter", + } + # ``;`` is unconditional: it is the delimiter this validator exists to + # protect, so an override must never be able to drop it. + rejected = ";" + forbidden + for i, ch in enumerate(value): + o = ord(ch) + if o < 0x20 or o > 0x7E: + raise ValueError( + f"{name} must be printable ASCII; " + f"got {ch!r} (U+{o:04X}) at position {i}" + ) + if ch in rejected: + raise ValueError( + f"{name} must not contain {ch!r} " + f"({reasons.get(ch, 'a delimiter')}); found at position {i}" + ) + + +def build_client_info_header( + *, + app_name: Optional[str] = None, + app_version: Optional[str] = None, + 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 ``"; "``): + + sdk[; client=<name>[/<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 falsy + (``None`` or empty string). Values must be printable ASCII + (``\x20``–``\x7e``) excluding ``;``, and ``app_name`` / ``app_version`` + additionally exclude ``/``; this is validated at construction time in + ``You.__init__`` and re-checked here as defense-in-depth. + + Args: + app_name: Optional name of the application or integration calling the + SDK. Falsy values drop the ``client=`` segment entirely, matching + how the MCP server omits it for callers that do not identify + themselves. + app_version: Optional version for ``app_name``, emitted as + ``client=<name>/<version>``. Ignored when ``app_name`` is falsy. + Note this differs from ``You.__init__``, which rejects that + combination outright: the constructor is where a caller mistake + should surface, while this builder stays permissive so it can + never be the thing that raises mid-request. + app_title: Optional caller-facing application title. Falsy + values drop the ``title=`` segment. + app_url: Optional caller-facing application URL. Falsy values + drop the ``url=`` segment. ``?x=1``-style query strings + survive the segment delimiter. + + Returns: + The header value to send over the wire. + + Raises: + ValueError: If any argument contains non-ASCII characters, control + characters, or a delimiter (``;`` for all of them, plus ``/`` + for ``app_name`` and ``app_version``). + + Side effects: + Lazily imports ``httpx`` to read its version for the ``ua=`` + segment. It is already loaded by the time ``You.search(...)`` + runs a real request, so this is a no-op lookup in practice, but + the lazy form is what keeps ``import youdotcom`` from pulling + transport into ``sys.modules``. + """ + # pylint: disable=import-outside-toplevel # lazy to keep httpx out + # of ``sys.modules`` at import time. + import httpx + + parts: list[str] = [_SOURCE_TOKEN] + + # ``client=`` identifies whoever is calling the SDK, not the SDK itself -- + # the same meaning the MCP server gives it, where the segment is dropped + # entirely for callers that do not identify themselves. The SDK's own + # version travels in the ``User-Agent``. Emitting ``client=youdotcom/<v>`` + # here instead would make the field constant across every row and so + # useless as an analytics dimension. + if app_name: + validate_attribution_arg("app_name", app_name, forbidden=";/") + client = app_name + if app_version: + validate_attribution_arg("app_version", app_version, forbidden=";/") + client = f"{client}/{app_version}" + parts.append(f"client={client}") + + if app_title: + validate_attribution_arg("app_title", app_title) + parts.append(f"title={app_title}") + + if app_url: + validate_attribution_arg("app_url", app_url) + parts.append(f"url={app_url}") + + py = sys.version_info + # Defensive: a vendored/forked/distro-patched httpx may lack the dunder or + # carry a non-ASCII or ``;``-bearing version. This segment is generated, not + # caller-supplied, so a bad value must degrade the analytics row rather than + # break every request (a raw ``;`` would forge a segment, and non-ASCII dies + # in httpx header encoding with no SDK frame in the traceback). + httpx_version = str(getattr(httpx, "__version__", "unknown")) + if not httpx_version.isascii() or any(c in httpx_version for c in ";/"): + httpx_version = "unknown" + parts.append( + f"ua=python/{py.major}.{py.minor}.{py.micro} 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..7c6efe6 --- /dev/null +++ b/tests/test_attribution.py @@ -0,0 +1,726 @@ +"""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 ``sdk`` token, the three optional + segments (``client=``, ``title=``, ``url=``) in the canonical order, + ``"; "`` separator throughout, no leading/trailing separators, no empty + segments when the optional args are falsy. Values must be printable + ASCII excluding ``;``, and ``app_name`` / ``app_version`` also exclude + ``/``; non-ASCII, control characters and delimiters are rejected to + prevent segment forgery, header injection, and encoding errors. + +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 contextlib +import importlib +import pathlib +import re +import importlib.metadata +import json +import sys +from unittest import mock + +import httpx +import pytest + +from youdotcom import You +from youdotcom.utils.attribution import ( + build_client_info_header, + validate_attribution_arg, +) + + +_SEARCH_BODY = json.dumps({"results": {"web": []}}) + + +@contextlib.contextmanager +def _capture(**you_kwargs): + """Yield ``(You, captured)`` over a mock transport, closing the client after. + + Mirrors the ``_capture()`` pattern in ``tests/test_extraction.py``, but + records request *headers* (lowercased, since HTTP header names are + case-insensitive) rather than the body. ``you_kwargs`` are forwarded to the + ``You`` constructor so a test can vary only what it cares about. + """ + 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, + **you_kwargs, + ) as you: + yield you, captured + finally: + client.close() + + +def _search_headers(**you_kwargs) -> dict: + """Run one synchronous search; return the headers that went over the wire.""" + with _capture(**you_kwargs) as (you, captured): + you.search(query="q") + return captured["headers"] + + +def _expected_default_header() -> str: + """The canonical header value when no attribution args are supplied.""" + return ( + f"sdk; ua=python/{sys.version_info.major}.{sys.version_info.minor}." + f"{sys.version_info.micro} httpx/{httpx.__version__}" + ) + + +# --------------------------------------------------------------------------- +# 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_sdk(self): + """The source token names the channel, matching `mcp` / `skill`. + + A single lowercase word is also the only shape that parses under the + analytics recipe as written; a hyphenated token makes the source column + come back empty on every SDK row. + """ + out = build_client_info_header() + assert out.startswith("sdk; "), out + assert out.split("; ")[0] == "sdk" + + def test_default_call_has_only_required_segments(self): + """Undeclared callers emit just the source token and the runtime. + + Mirrors the MCP server's `mcp; ua=...` row: `client=` is dropped + entirely rather than filled with the SDK's own identity, which would + make the column constant across every row. + """ + out = build_client_info_header() + assert out == ( + f"sdk; ua=python/{sys.version_info.major}.{sys.version_info.minor}." + f"{sys.version_info.micro} httpx/{httpx.__version__}" + ) + assert "client=" not in out + + def test_app_title_appended_after_client(self): + out = build_client_info_header( + app_name="acme-bot", app_version="2.4.0", app_title="MyAgent" + ) + # title= comes after client= and before ua= + parts = out.split("; ") + assert parts[0] == "sdk" + assert parts[1] == "client=acme-bot/2.4.0" + 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_name="acme-bot", + app_version="2.4.0", + app_title="MyAgent", + app_url="https://example.com", + ) + # canonical order: sdk, client=, title=, url=, ua= + parts = out.split("; ") + assert parts[0] == "sdk" + assert parts[1] == "client=acme-bot/2.4.0" + 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_never_names_the_sdk_itself(self): + """`client=` is caller identity, never the SDK's own. + + The SDK's identity and version travel in the `User-Agent`; duplicating + them here would make the segment constant for all SDK traffic. The + MCP server applies the same rule -- it never emits `client=mcp/...`. + """ + import youdotcom + + out = build_client_info_header() + assert "youdotcom" not in out + assert youdotcom.__version__ not in out + + def test_language_is_recoverable_from_the_ua_segment(self): + """`sdk` names the channel, so the language must come from `ua=`. + + This is the signal that distinguishes the Python SDK from the + TypeScript one (which reports `node/...`), and unlike the + `User-Agent` it cannot be overridden by a wrapping integration. + """ + out = build_client_info_header(app_name="youdotcom-temporal", app_version="1.0.1") + ua_seg = out[out.index("ua=") + len("ua="):] + assert ua_seg.startswith("python/") + + +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_name="acme-bot", app_version="2.4.0", app_title=title, app_url=url + ) + parts = out.split("; ") + assert parts[0] == "sdk" + assert parts[1] == "client=acme-bot/2.4.0" + assert parts[2] == f"title={title}" + assert parts[3] == f"url={url}" + assert parts[4].startswith("ua=") + + def test_empty_string_title_drops_segment(self): + """Empty string is falsy, so ``title=`` segment is dropped entirely.""" + out = build_client_info_header(app_title="") + assert "title=" not in out + + def test_empty_string_url_drops_segment(self): + """Empty string is falsy, so ``url=`` segment is dropped entirely.""" + out = build_client_info_header(app_url="") + assert "url=" not in out + + @pytest.mark.parametrize( + "bad_title", + [ + "Evil; url=http://attacker.com", + "line\rbreak", + "line\nbreak", + "Café Assistant", + "検索アシスタント", + "null\x00byte", + "vert\x0btab", + ], + ) + def test_invalid_title_raises(self, bad_title): + with pytest.raises(ValueError, match="app_title"): + build_client_info_header(app_title=bad_title) + + @pytest.mark.parametrize( + "bad_url", + [ + "http://evil.com; title=forged", + "http://evil.com\r", + "http://evil.com\n", + "http://café.com", + "http://例え.jp", + "http://evil.com\x00", + "http://evil.com\x0b", + ], + ) + def test_invalid_url_raises(self, bad_url): + with pytest.raises(ValueError, match="app_url"): + build_client_info_header(app_url=bad_url) + + +# --------------------------------------------------------------------------- +# Construction-time validation — You.__init__ must fail fast. +# --------------------------------------------------------------------------- + + +class TestConstructionTimeValidation: + """``You(app_title=..., app_url=...)`` validates at construction time.""" + + @pytest.mark.parametrize( + "bad_title", + [ + "Evil; url=http://attacker.com", + "Café Assistant", + "検索アシスタント", + "null\x00byte", + ], + ) + def test_invalid_app_title_raises_at_construction(self, bad_title): + with pytest.raises(ValueError, match="app_title"): + You(api_key_auth="k", app_title=bad_title) + + @pytest.mark.parametrize( + "bad_url", + [ + "http://evil.com; title=forged", + "http://café.com", + "http://evil.com\x00", + ], + ) + def test_invalid_app_url_raises_at_construction(self, bad_url): + with pytest.raises(ValueError, match="app_url"): + You(api_key_auth="k", app_url=bad_url) + + def test_valid_app_title_and_url_construct_fine(self): + with You( + api_key_auth="k", + server_url="http://mock.local", + app_title="MyAgent", + app_url="https://example.com", + ) as you: + assert you.sdk_configuration.app_title == "MyAgent" + + +# --------------------------------------------------------------------------- +# 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): + headers = _search_headers() + assert "x-client-info" in headers, ( + f"X-Client-Info not on the wire. Headers: {sorted(headers)}" + ) + assert headers["x-client-info"] == _expected_default_header() + + def test_app_title_and_url_propagate_to_wire(self): + headers = _search_headers( + app_name="acme-bot", + app_version="2.4.0", + app_title="MyAgent", + app_url="https://example.com", + ) + info = headers["x-client-info"] + assert "title=MyAgent" in info + assert "url=https://example.com" in info + # Order: sdk; client=; title=; url=; ua= + parts = info.split("; ") + assert parts[0] == "sdk" + assert parts[1] == "client=acme-bot/2.4.0" + assert parts[2] == "title=MyAgent" + assert parts[3] == "url=https://example.com" + + def test_integration_is_distinguishable_from_direct_sdk_use(self): + """The three traffic segments must be separable on the wire. + + Raw-HTTP callers send no header at all, so the interesting pair is + direct SDK use vs a first-party integration wrapping the SDK. `client=` + carries that distinction structurally, rather than depending on an + integration remembering to set a free-text title. + """ + direct = _search_headers()["x-client-info"] + wrapped = _search_headers( + app_name="youdotcom-temporal", app_version="1.0.1" + )["x-client-info"] + + assert "client=" not in direct + assert "client=youdotcom-temporal/1.0.1" in wrapped + # Both are still identifiably the Python SDK. + for info in (direct, wrapped): + assert info.split("; ")[0] == "sdk" + assert "; ua=python/" in info + + def test_caller_supplied_http_headers_override(self): + """A caller's own ``X-Client-Info`` wins. + + ``_build_request_with_client`` writes the attribution header *before* + merging per-call ``http_headers``, so an explicit caller value is not + clobbered. Pins that ordering. + """ + with _capture(app_title="MyAgent") as (you, captured): + you.search(query="q", http_headers={"X-Client-Info": "caller-wins"}) + assert captured["headers"]["x-client-info"] == "caller-wins" + + +class TestMcpAttributionNeverSent: + """The SDK must never emit ``X-MCP-Attribution``. + + Per DX-777: that header is assembled on the MCP server, which is the only + layer that can populate its ``keyless`` / ``payment`` / ``ip`` flags. The + SDK sits outside Cloudflare and has no ``CF-Connecting-IP`` to read, so + emitting it here would fabricate routing flags that the downstream + analytics recipe treats as authoritative. This is a negative contract, so + it needs a test that fails loudly if someone adds the header later. + """ + + @staticmethod + def _assert_absent(headers: dict) -> None: + offenders = [name for name in headers if "mcp" in name] + assert not offenders, ( + f"SDK emitted an MCP-specific header: {offenders}. " + "X-MCP-Attribution is the MCP server's responsibility." + ) + + def test_not_sent_with_no_attribution_args(self): + self._assert_absent(_search_headers()) + + def test_not_sent_with_attribution_args(self): + self._assert_absent( + _search_headers(app_title="MyAgent", app_url="https://example.com") + ) + + @pytest.mark.asyncio + async def test_not_sent_on_async_path(self): + """The async path builds its request through the same + ``_build_request_with_client``, but assert it rather than assume it.""" + 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 + ) + + async_client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + try: + async with You( + api_key_auth="k", + server_url="http://mock.local", + async_client=async_client, + timeout_ms=10_000, + app_title="MyAgent", + ) as you: + await you.search_async(query="q") + finally: + await async_client.aclose() + + assert "x-client-info" in captured["headers"] + self._assert_absent(captured["headers"]) + + +class TestVersionResolution: + """The SDK version resolves through ``importlib.metadata``. + + It reaches the wire via the ``User-Agent``, not ``X-Client-Info``: + ``client=`` carries the *caller*, so duplicating the SDK's own version + there would make the segment constant for all SDK traffic. These tests + pin the resolution path (editable / PEP 660 installs must report the + installed distribution version) and that split. + """ + + def test_sdk_version_is_carried_by_the_user_agent_not_the_header(self): + """The SDK version lives in ``User-Agent``, not ``X-Client-Info``. + + ``client=`` is caller identity, so the SDK's own version has exactly one + home. This pins that split, and the ``User-Agent`` half is what an + integration must preserve (append, not replace) to keep it visible. + """ + import youdotcom + from youdotcom._version import __user_agent__ + + assert youdotcom.__version__ in __user_agent__ + assert youdotcom.__version__ not in build_client_info_header() + + def test_version_prefers_installed_distribution_metadata(self): + """``_version.py`` overrides its literal with the installed metadata. + + Reloaded under a patched ``importlib.metadata.version`` so the test + does not depend on what is actually installed. Restores the real + module afterwards so later tests see the true version. + """ + import youdotcom._version as version_module + + try: + with mock.patch( + "importlib.metadata.version", return_value="4.5.6-from-metadata" + ): + reloaded = importlib.reload(version_module) + assert reloaded.__version__ == "4.5.6-from-metadata" + assert reloaded.__user_agent__.endswith("4.5.6-from-metadata") + finally: + importlib.reload(version_module) + + def test_missing_distribution_falls_back_to_literal(self): + """An uninstalled source checkout keeps the literal instead of raising.""" + import youdotcom._version as version_module + + try: + with mock.patch( + "importlib.metadata.version", + side_effect=importlib.metadata.PackageNotFoundError, + ): + reloaded = importlib.reload(version_module) + # Compare against pyproject, not just truthiness: a stale but + # non-empty literal is exactly the drift that shipped on 3.1.1. + pyproject = pathlib.Path(__file__).parent.parent / "pyproject.toml" + declared = re.search( + r'^version = "([^"]+)"', pyproject.read_text(), re.M + ).group(1) + assert reloaded.__version__ == declared + finally: + importlib.reload(version_module) + + +class TestClientSegmentValidation: + """`app_name` / `app_version` rules. + + These two feed `client=<name>/<version>`, which the analytics side splits + on `/` to derive `client_name` and `client_version`. A `/` inside either + half therefore corrupts both columns silently, so it is rejected the same + way `;` is -- fail at construction, not silently downstream. + """ + + def test_name_without_version_emits_bare_name(self): + out = build_client_info_header(app_name="acme-bot") + assert "; client=acme-bot; " in out + + def test_name_and_version_join_with_slash(self): + out = build_client_info_header(app_name="acme-bot", app_version="2.4.0") + assert "; client=acme-bot/2.4.0; " in out + + def test_version_without_name_is_dropped_by_the_builder(self): + """The builder has no slot for a bare version, so it emits nothing. + + `You.__init__` rejects this combination outright; the builder stays + permissive so it is never the thing that raises mid-request. + """ + out = build_client_info_header(app_version="2.4.0") + assert "client=" not in out + + @pytest.mark.parametrize("bad", ["acme/bot", "acme;bot", "acmé", "acme\nbot"]) + def test_invalid_app_name_raises(self, bad): + with pytest.raises(ValueError, match="app_name"): + build_client_info_header(app_name=bad) + + @pytest.mark.parametrize("bad", ["2/4", "2;4", "2.4.0é"]) + def test_invalid_app_version_raises(self, bad): + with pytest.raises(ValueError, match="app_version"): + build_client_info_header(app_name="acme-bot", app_version=bad) + + def test_version_without_name_raises_at_construction(self): + with pytest.raises(ValueError, match="app_version requires app_name"): + You(api_key_auth="k", app_version="2.4.0") + + @pytest.mark.parametrize( + "kwargs", + [ + {"app_name": "acme/bot"}, + {"app_name": "acme-bot", "app_version": "2/4"}, + ], + ) + def test_slash_rejected_at_construction(self, kwargs): + with pytest.raises(ValueError, match="client=<name>/<version>"): + You(api_key_auth="k", **kwargs) + + +class TestConstructorSignature: + """The attribution args are keyword-only; the pre-existing ones are not. + + Pinning both halves. The four attribution parameters are new in 3.1.2, so + making them keyword-only costs no caller anything and leaves room to add or + reorder attribution args later without a breaking change. The nine + parameters before them must stay positional-or-keyword, because + `You("api-key")` is a shape released callers may already depend on. + """ + + def test_attribution_args_are_keyword_only(self): + import inspect + + params = inspect.signature(You.__init__).parameters + for name in ("app_name", "app_version", "app_title", "app_url"): + assert params[name].kind is inspect.Parameter.KEYWORD_ONLY, name + + def test_api_key_still_accepted_positionally(self): + """Guards against a `*` creeping further up the signature.""" + import inspect + + params = inspect.signature(You.__init__).parameters + # All nine, not just the first: a `*` creeping up the signature would + # demote the rest and break `You(key, 0)` for released callers, which an + # api_key_auth-only assertion cannot see. + for name in ( + "api_key_auth", + "server_idx", + "url_params", + "server_url", + "client", + "async_client", + "retry_config", + "timeout_ms", + "debug_logger", + ): + assert ( + params[name].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + ), f"{name} must stay positional-or-keyword" + # `with` so the SDK-owned transports are closed; this suite runs under + # `error::ResourceWarning`. + with You("some-key") as client: + assert client.sdk_configuration.security is not None + + +class TestFalsyAttributionArgs: + """Falsy values must not slip past the pairing guard. + + The builder drops `client=` for any *falsy* `app_name`, so a guard written + against `is None` leaves a hole: `app_name=""` with a version passes + validation and then emits no `client=` at all, losing both values with no + error. `os.getenv("APP_NAME", "")` produces exactly that shape. + """ + + def test_empty_name_with_version_raises_at_construction(self): + with pytest.raises(ValueError, match="app_version requires app_name"): + You(api_key_auth="k", app_name="", app_version="2.4.0") + + def test_empty_name_with_version_omits_segment_in_the_builder(self): + """The builder stays permissive -- it must not raise, nor invent a segment. + + Only ``You.__init__`` rejects this combination; the builder runs + per-request, so it must never be the thing that raises mid-flight. + """ + out = build_client_info_header(app_name="", app_version="2.4.0") + assert "client=" not in out + + def test_empty_version_with_name_keeps_the_name(self): + """A falsy version is simply unset, which is a supported shape.""" + out = build_client_info_header(app_name="acme", app_version="") + assert "; client=acme; " in out + + def test_both_falsy_is_accepted_and_omits_the_segment(self): + with You(api_key_auth="k", app_name="", app_version="") as client: + assert client.sdk_configuration.app_name == "" + assert "client=" not in build_client_info_header(app_name="", app_version="") + + +class TestSemicolonAlwaysRejected: + """`;` rejection must survive a `forbidden` override. + + `forbidden` adds to the rejected set rather than replacing it. If it + replaced it, a caller passing `forbidden="/"` would silently lose the `;` + check -- reopening segment forgery on the one path this validator exists to + protect. + """ + + @pytest.mark.parametrize("forbidden", ["", "/", "@#"]) + def test_semicolon_rejected_regardless_of_override(self, forbidden): + with pytest.raises(ValueError, match="the segment delimiter"): + validate_attribution_arg("x", "a;b", forbidden=forbidden) + + def test_override_is_additive_not_a_replacement(self): + """One call must reject BOTH the override char and the always-on ``;``. + + Asserting only that ``forbidden="/"`` rejects ``/`` passes under + replacement semantics too, so it proves nothing about additivity. The + discriminating assertion is that ``;`` is still rejected by that same + call. + """ + validate_attribution_arg("x", "a/b") # `/` is not forbidden by default + with pytest.raises(ValueError, match="the client=<name>/<version>"): + validate_attribution_arg("app_name", "a/b", forbidden="/") + with pytest.raises(ValueError, match="the segment delimiter"): + validate_attribution_arg("app_name", "a;b", forbidden="/") + + +class TestAttributionValueTypesAndWhitespace: + """Values are interpolated verbatim, so shape errors must fail fast. + + Every doc surface promises `ValueError` at construction time. Without a type + guard a non-`str` either raises an opaque `TypeError` from inside the + character loop, or -- worse -- a list of single characters passes every + check and ships `title=['a', 'b']`. Whitespace is the same class: falsy + gates downstream treat `" "` as absent, so it ships `client= /1.0`, and a + padded value becomes an analytics key that never groups with its unpadded + rows. + """ + + @pytest.mark.parametrize("bad", [["a", "b"], 123, 2.4, b"acme", None.__class__]) + def test_non_str_raises_value_error_naming_the_param(self, bad): + with pytest.raises(ValueError, match="app_title must be a str"): + build_client_info_header(app_title=bad) + + def test_list_of_chars_does_not_slip_through(self): + """Each element is a 1-char printable string, so the loop alone passes.""" + with pytest.raises(ValueError, match="must be a str"): + build_client_info_header(app_title=["a", "b"]) + + @pytest.mark.parametrize("bad", [" ", " ", "\t", " acme ", "acme "]) + def test_whitespace_padded_or_only_raises(self, bad): + with pytest.raises(ValueError, match="whitespace"): + build_client_info_header(app_name=bad) + + def test_interior_space_is_still_allowed(self): + """Only the edges are rejected; `title=Acme Bot` is a legitimate value.""" + assert "title=Acme Bot" in build_client_info_header(app_title="Acme Bot") + assert "client=acme bot" in build_client_info_header(app_name="acme bot") + + def test_whitespace_name_with_version_cannot_ship_an_empty_client(self): + with pytest.raises(ValueError, match="whitespace"): + You(api_key_auth="k", app_name=" ", app_version="1.0") + + +class TestGeneratedUaSegmentIsDefensive: + """The one segment the builder generates itself must never break a request. + + `httpx.__version__` is interpolated without caller involvement, so a + vendored, forked or distro-patched httpx could inject `;` (forging a + segment) or non-ASCII (dying inside httpx header encoding, with no SDK + frame in the traceback), or omit the dunder entirely. + """ + + @pytest.mark.parametrize( + "version", ["0.28.1; client=forged/9.9.9", "0.28.1-café", "1.0/2"] + ) + def test_malformed_httpx_version_degrades_to_unknown(self, version): + with mock.patch.object(httpx, "__version__", version): + out = build_client_info_header() + assert out.endswith("httpx/unknown") + assert out.count(";") == 1 # only the sdk -> ua separator + + def test_missing_dunder_does_not_raise(self): + with mock.patch.object(httpx, "__version__", None): + delattr(httpx, "__version__") + try: + assert build_client_info_header().endswith("httpx/unknown") + finally: + httpx.__version__ = httpx.__dict__.get("__version__") or "0.28.1" + + def test_normal_version_is_passed_through(self): + with mock.patch.object(httpx, "__version__", "0.28.1"): + assert build_client_info_header().endswith("httpx/0.28.1") diff --git a/tests/test_live.py b/tests/test_live.py index 87d33e1..f579a67 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -16,6 +16,8 @@ """ import os + +import httpx import pytest from youdotcom import You @@ -862,16 +864,116 @@ 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().""" - with you_client as you: + # ``async with`` (not ``with``) so the async transport is closed on + # exit; the sync context manager leaves it open, which surfaces as an + # unclosed-socket ResourceWarning at interpreter teardown. Matches the + # convention every other async test in the suite uses. + async with you_client as you: res = await you.answer_async(query="What is 2+2?") assert isinstance(res, AnswerResponse) assert len(res.answer) > 0 +@requires_api_key +class TestLiveAttribution: + """The ``X-Client-Info`` header on real requests (DX-777). + + The mock-transport tests in ``tests/test_attribution.py`` pin the wire + format; what they cannot show is that the real API *accepts* the header. + An unknown header that tripped a WAF rule or a strict gateway would fail + only against prod, so this asserts both halves: the header went out on + every request, and the live call still succeeded. + + Uses an ``httpx`` request event hook to observe the outbound headers, + accumulating them into a list and asserting on the accumulated matches -- + the contract-list pattern AGENTS.md prescribes for live tests. + """ + + @staticmethod + def _client_with_hook(observed: list): + """A real httpx client that records the headers of each request.""" + + def record(request: httpx.Request) -> None: + # Lowercase the keys on the way in. httpx already normalizes, but + # HTTP header names are case-insensitive, so pinning the casing + # here is what lets every assertion below index directly instead + # of guarding each lookup. + observed.append({k.lower(): v for k, v in request.headers.items()}) + + return httpx.Client( + follow_redirects=True, event_hooks={"request": [record]} + ) + + def test_x_client_info_sent_and_accepted_live(self, api_key): + observed: list = [] + http_client = self._client_with_hook(observed) + try: + with You( + api_key_auth=api_key, + timeout_ms=LIVE_TIMEOUT_MS, + client=http_client, + app_name="sdk-live-test", + app_version="0.0.1", + app_title="sdk-live-test", + app_url="https://example.com/live?x=1", + ) as you: + res = you.search(query="You.com Python SDK") + + # The live call itself must succeed -- i.e. the header did not + # trip a gateway or WAF rule on the way in. + assert res.results is not None + + matches = [h["x-client-info"] for h in observed if "x-client-info" in h] + assert matches, ( + "no request carried X-Client-Info; " + f"headers seen: {[sorted(h) for h in observed]}" + ) + for value in matches: + assert value.startswith("sdk; client=sdk-live-test/0.0.1") + assert "title=sdk-live-test" in value + # Query strings must survive the segment delimiter. + assert "url=https://example.com/live?x=1" in value + assert "; ua=python/" in value + assert "httpx/" in value + finally: + http_client.close() + + def test_x_mcp_attribution_absent_live(self, api_key): + """The SDK never sets the MCP-side header, on a real request either.""" + observed: list = [] + http_client = self._client_with_hook(observed) + try: + with You( + api_key_auth=api_key, + timeout_ms=LIVE_TIMEOUT_MS, + client=http_client, + ) as you: + you.search(query="You.com Python SDK") + + assert observed, "no request was observed" + offenders = [ + name for headers in observed for name in headers if "mcp" in name + ] + assert not offenders, f"SDK emitted MCP-specific headers: {offenders}" + finally: + http_client.close() + + if __name__ == "__main__": # Run with: python -m pytest tests/test_live.py -v pytest.main([__file__, "-v"]) 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..45bacae --- /dev/null +++ b/tests/test_researchtaskstreamevent.py @@ -0,0 +1,311 @@ +"""Tests for ``ResearchTaskStreamEvent`` model-level contracts. + +The SSE ``event`` discriminator must accept any string the server +emits, including names the SDK does not enumerate. ``Event`` uses +:class:`OpenEnumMeta` so unknown values unmarshal as plain strings +without serialization warnings. + +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 plain ``str`` values that compare + equal to their raw string. +- Serialization (``model_dump``) produces no warnings for unknown + events (the ``OpenEnumMeta`` switch fixes the prior + ``PydanticSerializationUnexpectedValue`` warnings). +""" + +from __future__ import annotations + +import warnings +from typing import get_args, get_type_hints + +import httpx +import pytest + +from youdotcom import You +from youdotcom.models.researchtaskstreamevent import ( + Event, + EventName, + ResearchTaskStreamEvent, + ResearchTaskStreamEventTypedDict, +) + + +_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.""" + 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 plain strings.""" + + @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_str( + self, future_event_name: str + ) -> None: + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": future_event_name, "data": _EVENT_DATA} + ) + assert isinstance(evt.event, str) + assert not isinstance(evt.event, Event) + assert evt.event == future_event_name + + def test_unknown_event_equality_against_raw_string(self) -> None: + """``evt.event == 'whatever'`` returns True for unknown names.""" + 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} + ) + 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.""" + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "checkpoint", "data": _EVENT_DATA} + ) + assert isinstance(evt.event, str) + assert not isinstance(evt.event, Event) + + def test_unknown_event_membership_check_in_set(self) -> None: + """``evt.event in {'a', 'b', 'retry'}`` works for unknown names.""" + 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" + + def test_unknown_event_round_trip_no_warnings(self) -> None: + """``model_dump`` on an unknown event must not emit warnings. + + The ``OpenEnumMeta`` switch fixes the prior + ``PydanticSerializationUnexpectedValue`` warnings that + ``Union[Event, UnrecognizedStr]`` produced. + """ + evt = ResearchTaskStreamEvent.model_validate( + {"id": "1", "event": "retry", "data": _EVENT_DATA} + ) + with warnings.catch_warnings(): + warnings.simplefilter("error") + dumped = evt.model_dump(by_alias=True) + assert dumped["event"] == "retry" + + +class TestResearchTaskStreamEventDeclaredType: + """The declared type of ``event`` must admit the plain-``str`` case. + + Every other test in this module passes whether the field is annotated + ``Event`` or ``EventName``, because they assert runtime behavior and + the runtime behavior comes from ``OpenEnumMeta``. The annotation is a + separate contract: annotated ``Event``, a caller writing + ``evt.event.value`` type-checks clean and then raises + ``AttributeError`` the first time the server emits an unenumerated + name, and the ``isinstance(evt.event, Event)`` guard the docstring + prescribes narrows to ``Never``. Pinning it here is what keeps the + declared type honest. + """ + + def test_event_field_annotation_admits_str(self) -> None: + annotation = ResearchTaskStreamEvent.model_fields["event"].annotation + assert annotation is EventName, ( + "ResearchTaskStreamEvent.event must be annotated EventName " + f"(Union[Event, str]), got {annotation!r}. A bare Event " + "annotation hides the unknown-event-name case from type checkers." + ) + assert set(get_args(EventName)) == {Event, str} + + def test_typed_dict_annotation_matches_model(self) -> None: + assert ( + get_type_hints(ResearchTaskStreamEventTypedDict)["event"] + is ResearchTaskStreamEvent.model_fields["event"].annotation + ) + + +class TestStreamDecodePath: + """End-to-end pin through the real SSE decode path (DX-778). + + The tests above validate the model directly. That is not the path a caller + exercises: ``stream_research_task`` wraps every SSE frame in + ``unmarshal_json_response``, which re-raises any pydantic failure as + ``ResponseValidationError``. A regression could therefore leave the + model-level tests green while the streaming API still blows up on a new + server event name, so this drives the generated method over a + ``MockTransport`` stream carrying both known and unknown event names. + """ + + _FRAMES = [ + b"id: 0\nevent: connected\ndata: " + b'{"type":"connected","task_id":"abc","status":"running"}\n\n', + # Not in the Event enum -- the exact case DX-778 is about. + b"id: 1\nevent: research.searching\ndata: " + b'{"type":"research.searching","task_id":"abc","status":"running"}\n\n', + b"id: 2\nevent: checkpoint\ndata: " + b'{"type":"checkpoint","task_id":"abc","status":"running"}\n\n', + b"id: 3\nevent: response.done\ndata: " + b'{"type":"response.done","task_id":"abc","status":"completed"}\n\n', + ] + + def _stream_events(self): + def handler(request): + return httpx.Response( + 200, + headers={"content-type": "text/event-stream"}, + content=b"".join(self._FRAMES), + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with You( + api_key_auth="k", server_url="http://mock.local", client=client + ) as you: + with you.stream_research_task( + task_id="00000000-0000-0000-0000-000000000001" + ) as stream: + return list(stream) + finally: + client.close() + + def test_unknown_event_names_do_not_raise_on_the_stream_path(self): + """No ``ResponseValidationError`` for any frame, known or unknown.""" + events = self._stream_events() + assert [e.event for e in events] == [ + "connected", + "research.searching", + "checkpoint", + "response.done", + ] + + def test_stream_path_types_split_known_from_unknown(self): + events = {e.event: e.event for e in self._stream_events()} + assert isinstance(events["connected"], Event) + assert isinstance(events["response.done"], Event) + assert not isinstance(events["research.searching"], Event) + assert not isinstance(events["checkpoint"], Event) + assert type(events["checkpoint"]) is str # noqa: E721 + + def test_stream_path_emits_no_warnings(self): + with warnings.catch_warnings(): + warnings.simplefilter("error") + events = self._stream_events() + assert len(events) == len(self._FRAMES) + + +class TestFrameCoverageDivergence: + """`stream_research_task` drops data-less frames; `stream_research` keeps them. + + This is the *actual* remaining reason to prefer the helper now that both + paths tolerate unenumerated event names, and it was previously documented + wrongly as a retry difference (stream-open retries are opt-in, so a default + client performs none). Pinning it here so the documented rationale on four + surfaces cannot drift away from the code again. + """ + + _FRAMES = ( + b"id: 0\nevent: ping\n\n" # data-less keep-alive + b'id: 1\nevent: connected\ndata: {"type":"connected"}\n\n' + b'id: 2\nevent: response.done\ndata: {"type":"response.done"}\n\n' + ) + + def _handler(self, request): + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, content=self._FRAMES + ) + + def test_generated_method_drops_data_less_frames(self): + client = httpx.Client(transport=httpx.MockTransport(self._handler)) + try: + with You( + api_key_auth="k", server_url="http://mock.local", client=client + ) as you: + with you.stream_research_task( + task_id="00000000-0000-0000-0000-000000000001" + ) as stream: + seen = [e.event for e in stream] + finally: + client.close() + assert "ping" not in seen + assert seen == ["connected", "response.done"] + + def test_helper_yields_data_less_frames(self): + from youdotcom.research_helpers import stream_research + + client = httpx.Client(transport=httpx.MockTransport(self._handler)) + try: + with You( + api_key_auth="k", server_url="http://mock.local", client=client + ) as you: + seen = [ + e.event + for e in stream_research( + you, task_id="00000000-0000-0000-0000-000000000001" + ) + ] + finally: + client.close() + assert seen == ["ping", "connected", "response.done"] diff --git a/tests/test_root_init.py b/tests/test_root_init.py new file mode 100644 index 0000000..78dbc36 --- /dev/null +++ b/tests/test_root_init.py @@ -0,0 +1,208 @@ +"""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 os +import pathlib +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. + + ``PYTHONPATH`` is set to the repo's ``src`` explicitly. A subprocess does + not inherit pytest's ``pythonpath = ["src"]`` injection, so relying on the + package being installed makes these tests fail confusingly (a + ``ModuleNotFoundError`` for ``youdotcom``, reported as a transport leak) + for anyone running the suite against a bare checkout. + """ + src = pathlib.Path(__file__).resolve().parent.parent / "src" + env = {**os.environ} + env["PYTHONPATH"] = ( + f"{src}{os.pathsep}{env['PYTHONPATH']}" if env.get("PYTHONPATH") else str(src) + ) + result = subprocess.run( + [sys.executable, "-c", textwrap.dedent(snippet)], + capture_output=True, + text=True, + check=False, + timeout=60, + env=env, + ) + 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 + + # Deliberately NO `import youdotcom.models` here: importing a submodule + # binds it as an attribute on the parent package, which would satisfy + # the hasattr below without ever reaching the PEP 562 __getattr__ this + # test exists to exercise. + missing = [ + name + for name in ("models", "errors", "utils", "types") + # sub-package attribute access must not raise AttributeError + if not hasattr(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}" + + +def test_star_import_binds_documented_surface() -> None: + """``from youdotcom import *`` binds every name in ``__all__``. + + The sub-packages are the part worth guarding: ``import *`` binds + exactly ``__all__``, so a sub-package reachable via attribute access + (``youdotcom.models``) can still silently drop out of the star-import + surface. Code doing ``from youdotcom import *`` followed by + ``models.SearchRequestBody(...)`` worked through 3.1.1 and must keep + working. ``test_root_import_exposes_subpackages`` covers attribute + access and would pass even with the star-import surface broken. + """ + snippet = """ + import sys + + import youdotcom + + namespace = {} + exec("from youdotcom import *", namespace) + + missing = [name for name in youdotcom.__all__ if name not in namespace] + if missing: + print("MISSING_FROM_STAR_IMPORT:", missing) + sys.exit(2) + + # Spot-check that a star-imported sub-package is actually usable, + # not just bound to something truthy. + models = namespace["models"] + if models.SearchRequestBody(query="x").query != "x": + print("SUBPACKAGE_UNUSABLE") + sys.exit(3) + + for name in ("models", "errors", "utils", "types"): + if name not in namespace: + print("SUBPACKAGE_NOT_STAR_IMPORTED:", name) + sys.exit(4) + """ + rc, stdout, stderr = _run_in_subprocess(snippet) + assert rc == 0, f"star-import surface regressed: {stdout!r} {stderr!r}" + + +def test_star_import_does_not_clobber_consumer_dunders() -> None: + """``import *`` must not bind the version dunders. + + Without ``__all__`` CPython skips underscore names on a star import; + naming them in ``__all__`` binds them. A consumer package that sets its + own ``__version__`` in ``__init__.py`` and then star-imports the SDK would + silently report the SDK's version as its own -- wrong output in a CLI + ``--version``, a setuptools dynamic version, or an ``importlib.metadata`` + fallback. They must stay reachable as attributes either way. + """ + snippet = """ + import sys + + namespace = {"__version__": "1.0.0-consumer", "__title__": "consumer-pkg"} + exec("from youdotcom import *", namespace) + + clobbered = { + name: namespace[name] + for name, original in ( + ("__version__", "1.0.0-consumer"), + ("__title__", "consumer-pkg"), + ) + if namespace[name] != original + } + if clobbered: + print("CONSUMER_DUNDERS_CLOBBERED:", clobbered) + sys.exit(2) + + # ...but they must still be importable explicitly. + from youdotcom import __title__, __version__ # noqa: F401 + + if not __version__: + print("DUNDER_NOT_REACHABLE") + sys.exit(3) + """ + rc, stdout, stderr = _run_in_subprocess(snippet) + assert rc == 0, f"star-import clobbered consumer dunders: {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" }, From d84b53be4f3943bb2bdef683f694707ac00e2873 Mon Sep 17 00:00:00 2001 From: Tyler Eastman <tyler@you.com> Date: Thu, 20 Aug 2026 23:41:32 -0700 Subject: [PATCH 2/2] fix: address P1/P2 review findings on PR #49 - Case-insensitive X-Client-Info override: delete existing headers that case-insensitively match caller-provided keys before merging, so httpx doesn't coalesce two entries as "a, b" - httpx.__version__ sanitization: replace isascii() with validate_attribution_arg to reject ASCII control characters - answer()/answer_async() safesearch type hint: Optional[str] -> Optional[Union[str, models.SafeSearch]] for enum discoverability - MIGRATION.md: fix broken anchor #root-namespace-narrowing-1 -> #root-namespace-narrowing - README.md: add note that API snippets assume the Quickstart client - Tests: case-insensitive header override + control char degradation Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- MIGRATION.md | 2 +- README.md | 2 ++ src/youdotcom/basesdk.py | 11 +++++++++++ src/youdotcom/sdk.py | 4 ++-- src/youdotcom/utils/attribution.py | 4 +++- tests/test_attribution.py | 19 +++++++++++++++++++ 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 39fb9e5..342e9d5 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -306,7 +306,7 @@ with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: | Change | Who is affected | What to do | |--------|-----------------|------------| -| Import machinery no longer re-exported from the package root | Anyone importing a stdlib/typing name or internal helper *from* `youdotcom` | Import it from its real home. See [Root namespace narrowing](#root-namespace-narrowing-1) | +| Import machinery no longer re-exported from the package root | Anyone importing a stdlib/typing name or internal helper *from* `youdotcom` | Import it from its real home. See [Root namespace narrowing](#root-namespace-narrowing) | | `ResearchTaskStreamEvent.event` is typed `Union[Event, str]` | Type-checked code calling `evt.event.value` | Guard with `isinstance(evt.event, Event)`. See [SSE event names](#sse-event-names) | ### Root namespace narrowing diff --git a/README.md b/README.md index 3d792c8..5403e7f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ strings work in any case. `country="us"` and `safesearch="STRICT"` are both accepted. Elsewhere, pass the value as the API spells it (all lowercase) or import the enum from `youdotcom.models`. +The snippets below assume the [Quickstart](#quickstart) client is in scope as `you`. + ### Answer A synthesized answer with citations, grounded in live web results. diff --git a/src/youdotcom/basesdk.py b/src/youdotcom/basesdk.py index 7230aed..bc860a2 100644 --- a/src/youdotcom/basesdk.py +++ b/src/youdotcom/basesdk.py @@ -242,6 +242,17 @@ def _build_request_with_client( headers["content-type"] = serialized_request_body.media_type if http_headers is not None: + # Delete existing headers that case-insensitively match + # caller-provided keys so the caller's value replaces the + # SDK's, not coalesces with it. Without this, a caller + # passing ``{"x-client-info": ...}`` (lowercase) leaves both + # ``X-Client-Info`` (SDK) and ``x-client-info`` (caller) in + # the dict; httpx sends both raw lines and coalesces them as + # ``"a, b"``, breaking the ``"; "``-delimited grammar. + lowered = {k.lower() for k in http_headers} + for existing in list(headers): + if existing.lower() in lowered: + del headers[existing] for header, value in http_headers.items(): headers[header] = value diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 3154c5d..81f0508 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -448,7 +448,7 @@ def answer( ] = None, country: Optional[Union[str, models.Country]] = None, language: Optional[Union[str, models.Language]] = None, - safesearch: Optional[str] = None, + safesearch: Optional[Union[str, models.SafeSearch]] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, @@ -599,7 +599,7 @@ async def answer_async( ] = None, country: Optional[Union[str, models.Country]] = None, language: Optional[Union[str, models.Language]] = None, - safesearch: Optional[str] = None, + safesearch: Optional[Union[str, models.SafeSearch]] = None, include_domains: Optional[Iterable[str]] = None, exclude_domains: Optional[Iterable[str]] = None, boost_domains: Optional[Iterable[str]] = None, diff --git a/src/youdotcom/utils/attribution.py b/src/youdotcom/utils/attribution.py index 0a1bc0c..3aaa111 100644 --- a/src/youdotcom/utils/attribution.py +++ b/src/youdotcom/utils/attribution.py @@ -175,7 +175,9 @@ def build_client_info_header( # break every request (a raw ``;`` would forge a segment, and non-ASCII dies # in httpx header encoding with no SDK frame in the traceback). httpx_version = str(getattr(httpx, "__version__", "unknown")) - if not httpx_version.isascii() or any(c in httpx_version for c in ";/"): + try: + validate_attribution_arg("httpx.__version__", httpx_version, forbidden="/") + except ValueError: httpx_version = "unknown" parts.append( f"ua=python/{py.major}.{py.minor}.{py.micro} httpx/{httpx_version}" diff --git a/tests/test_attribution.py b/tests/test_attribution.py index 7c6efe6..3a5dc1e 100644 --- a/tests/test_attribution.py +++ b/tests/test_attribution.py @@ -379,6 +379,17 @@ def test_caller_supplied_http_headers_override(self): you.search(query="q", http_headers={"X-Client-Info": "caller-wins"}) assert captured["headers"]["x-client-info"] == "caller-wins" + def test_caller_supplied_http_headers_override_case_insensitive(self): + """A caller's ``X-Client-Info`` wins even when the case differs. + + HTTP header names are case-insensitive, so ``http_headers={"x-client-info": ...}`` + must replace the SDK's ``X-Client-Info``, not coalesce with it as + ``"sdk-value, caller-wins"`` (which breaks the ``"; "``-delimited grammar). + """ + with _capture(app_title="MyAgent") as (you, captured): + you.search(query="q", http_headers={"x-client-info": "caller-wins"}) + assert captured["headers"]["x-client-info"] == "caller-wins" + class TestMcpAttributionNeverSent: """The SDK must never emit ``X-MCP-Attribution``. @@ -713,6 +724,14 @@ def test_malformed_httpx_version_degrades_to_unknown(self, version): assert out.endswith("httpx/unknown") assert out.count(";") == 1 # only the sdk -> ua separator + @pytest.mark.parametrize("version", ["0.28.1\r\n", "0.28.1\x00", "0.28.1\x0b"]) + def test_control_chars_in_httpx_version_degrade_to_unknown(self, version): + """ASCII control characters pass ``isascii()`` but break header encoding.""" + with mock.patch.object(httpx, "__version__", version): + out = build_client_info_header() + assert out.endswith("httpx/unknown") + assert out.count(";") == 1 + def test_missing_dunder_does_not_raise(self): with mock.patch.object(httpx, "__version__", None): delattr(httpx, "__version__")