From c8dda6b74c30050171c2ae63197f8a3b71a17624 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 12:30:53 -0700 Subject: [PATCH 01/24] feat: add knowledge param and knowledge result models to search (DX-835) Adds support for the new `knowledge` request parameter on POST /v1/search and the knowledge result models that come back with it. Request: `knowledge="core"` on `search()` / `search_async()` and the deprecated `you.search.unified*` shims. Normalized to lowercase like the other enum-typed params; an invalid value raises ValidationError locally before any network call, mirroring the server's 422. All params on these methods are keyword-only, so the addition is non-breaking. Response: `Results.knowledge` is an optional list of `KnowledgeResult`, each carrying `type`, `title`, and `attribution`, plus `description` and an optional `as_of` date for `type: answer` results. `type` is a plain `str` so an unrecognized future kind parses instead of raising. The API omits the key entirely when nothing relevant is found, so the field is None rather than an empty list. Also fixes a blind spot in scripts/check_drift.py: the response-schema check compared top-level fields only, so a new nested field such as `results.knowledge` was invisible. It now recurses through nested object schemas and arrays, and falls back to the previous flat comparison when a response resolves to a oneOf union rather than a single object schema. Recursion surfaced two pre-existing gaps unrelated to this change (AnswerSearchResult.description/.thumbnail_url and FinanceResearchSource.snippets); those are listed in an explicit KNOWN_RESPONSE_GAPS table that reports itself stale once the SDK catches up. Verified: 425 offline tests pass, 6 live knowledge tests pass against prod, mypy clean over 85 files, pylint 10.00/10 on the CI gate, and the drift check is clean in both --verbose and --strict modes. --- CHANGELOG.md | 39 +++ README.md | 29 ++ USAGE.md | 40 +++ docs/models/knowledge.md | 24 ++ docs/models/knowledgeattribution.md | 31 +++ docs/models/knowledgeresult.md | 67 +++++ docs/models/results.md | 9 +- docs/models/searchrequestbody.md | 1 + docs/sdks/search/README.md | 1 + docs/sdks/you/README.md | 1 + examples/api-example-calls.py | 35 +++ pyproject.toml | 2 +- scripts/check_drift.py | 142 +++++++++- src/youdotcom/_shims.py | 6 + src/youdotcom/_version.py | 2 +- src/youdotcom/models/__init__.py | 16 ++ src/youdotcom/models/knowledge.py | 10 + src/youdotcom/models/knowledgeattribution.py | 38 +++ src/youdotcom/models/knowledgeresult.py | 64 +++++ src/youdotcom/models/searchrequestbody.py | 7 + src/youdotcom/models/searchresponse.py | 8 +- src/youdotcom/sdk.py | 25 +- tests/test_knowledge.py | 266 +++++++++++++++++++ tests/test_live.py | 81 ++++++ tests/test_param_normalization.py | 9 +- tests/test_performance.py | 25 ++ uv.lock | 2 +- 27 files changed, 962 insertions(+), 18 deletions(-) create mode 100644 docs/models/knowledge.md create mode 100644 docs/models/knowledgeattribution.md create mode 100644 docs/models/knowledgeresult.md create mode 100644 src/youdotcom/models/knowledge.py create mode 100644 src/youdotcom/models/knowledgeattribution.py create mode 100644 src/youdotcom/models/knowledgeresult.py create mode 100644 tests/test_knowledge.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2595420..08c43d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ 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.5.0] - 2026-09-21 + +Minor release. Adds support for the new `knowledge` parameter on +`POST /v1/search` and the knowledge result models that come back with it. +Purely additive — no breaking changes. + +### Added + +- **`knowledge` parameter on `search()` and `search_async()`** — pass + `knowledge="core"` to request knowledge results backed by licensed data + providers such as encyclopedias, market-data firms, and reference publishers. + They arrive in their own section at `response.results.knowledge`. `"core"` is + the only value the API accepts, and anything else raises `ValidationError` + locally rather than reaching the network, mirroring the server's `422`. +- **`Knowledge` enum** — `Knowledge.CORE`, exported from `youdotcom.models`. + Plain strings are accepted and normalized, so `knowledge="core"` and + `knowledge="CORE"` both work. +- **`KnowledgeResult` and `KnowledgeAttribution` models** — a knowledge result + carries `type`, `title`, and `attribution`, plus `description` and an optional + `as_of` date for `type: answer` results, the only kind returned today. `type` + is modeled as a plain `str` so an unrecognized kind parses instead of raising, + since a new kind may populate a different set of fields. Attribution entries + are credits rather than citations and carry no URL. +- **`Results.knowledge`** — new optional field on the search response container. + Up to 25 results are returned, limited to those relevant to the query. When + none are relevant the API omits the key entirely, so the field is `None` + rather than an empty list and iterating needs an `or []` guard. `count` caps + the web and news sections, not knowledge. + +### Changed + +- **`scripts/check_drift.py` recurses nested response schemas** — the response + check previously compared top-level fields only, so drift inside a nested + object went undetected. Two pre-existing gaps that recursion surfaced + (`AnswerSearchResult.description` / `.thumbnail_url` and + `FinanceResearchSource.snippets`) are listed in an explicit + `KNOWN_RESPONSE_GAPS` table that reports itself stale once the SDK catches up, + rather than being silently ignored. + ## [3.4.0] - 2026-09-08 Minor release. The `metadata` format on the Contents API is now deprecated diff --git a/README.md b/README.md index 20e527f..a947acd 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,35 @@ available and crawls the page live otherwise, `"cache"` returns cached content only (`contents` is omitted for results with none), and `"fetch"` always crawls the page live. +#### Knowledge results + +Pass `knowledge="core"` to add cards backed by licensed data providers — +encyclopedias, market-data firms, reference publishers. They come back in +their own section: + +```python +res = you.search( + query="what is the capital of France", + knowledge="core", +) + +for card in res.results.knowledge or []: + print(card.title) + print(card.description) + print([credit.name for credit in card.attribution]) +``` + +`"core"` is the only value the API accepts; anything else raises +`ValidationError` locally, mirroring the server's `422`. Results are limited +to those relevant to the query, up to 25, and when none are relevant the API +omits the section entirely — so `results.knowledge` is `None` rather than an +empty list. Iterate with `or []`. + +`count` caps the web and news sections, not knowledge. Attribution entries +are credits rather than citations: each names a provider and carries no URL. +`as_of`, when present, is the `YYYY-MM-DD` date the card's underlying data +covers. + ### Contents Clean HTML or Markdown for a list of URLs. diff --git a/USAGE.md b/USAGE.md index 012e571..2adde62 100644 --- a/USAGE.md +++ b/USAGE.md @@ -100,6 +100,46 @@ Unknown keys inside `extraction` raise `ValidationError` locally, and passing `ValueError` — both mirror the server's 422 contract so callers fail-fast. + +```python +# Add knowledge cards backed by licensed data providers. +import os +from youdotcom import You + + +with You( + api_key_auth=os.getenv("YDC_API_KEY"), + timeout_ms=60_000, +) as you: + + res = you.search( + query="what is the capital of France", + knowledge="core", + ) + + for card in res.results.knowledge or []: + print(card.title, card.description) + print([credit.name for credit in card.attribution]) +``` + +`knowledge="core"` requests knowledge results — cards backed by licensed data +providers such as encyclopedias, market-data firms, and reference publishers. +`"core"` is the only value the API accepts; anything else raises +`ValidationError` locally, mirroring the server's 422. + +Results are limited to those relevant to the query, up to 25. When none are +relevant the API omits the section, so `results.knowledge` is `None` rather +than an empty list — iterate with `or []`. `count` caps the web and news +sections, not knowledge. + +Each card carries `type`, `title`, and `attribution`; for `type: answer` — the +only kind returned today — `description` is present and `as_of` is an optional +`YYYY-MM-DD` date covering the card's underlying data. `type` is a plain +string so an unrecognized kind parses rather than raises; ignore a value you +do not recognize. Attribution entries are credits rather than citations: each +names a provider and carries no URL. + + ```python # Tag every outbound request with a caller-identity header so the diff --git a/docs/models/knowledge.md b/docs/models/knowledge.md new file mode 100644 index 0000000..4ac4e67 --- /dev/null +++ b/docs/models/knowledge.md @@ -0,0 +1,24 @@ +# Knowledge + +Requests knowledge results alongside web and news search. + +## Example Usage + +```python +from youdotcom.models import Knowledge + +value = Knowledge.CORE +``` + + +## Values + +| Name | Value | +| ------ | ----- | +| `CORE` | core | + +## Notes + +`core` is the only value the API accepts. Passing anything else raises +`ValidationError` locally rather than reaching the network, mirroring the +server's `422`. diff --git a/docs/models/knowledgeattribution.md b/docs/models/knowledgeattribution.md new file mode 100644 index 0000000..d7b79f5 --- /dev/null +++ b/docs/models/knowledgeattribution.md @@ -0,0 +1,31 @@ +# KnowledgeAttribution + +Display credit for the data behind a [KnowledgeResult](../models/knowledgeresult.md). + +## Example Usage + +```python +from youdotcom.models import KnowledgeAttribution + +credit = KnowledgeAttribution.model_validate({"name": "Encyclopedia Britannica"}) + +print(credit.name) # "Encyclopedia Britannica" +print(credit.source_description) # None when the provider reports none +``` + +Attribution entries arrive nested under `KnowledgeResult.attribution`. Given a +raw payload, `KnowledgeAttribution.model_validate(data)` builds one from a dict +matching `KnowledgeAttributionTypedDict`. + +## Fields + +| Field | Type | Required | Description | Example | +| -------------------- | --------------- | ------------------ | --------------------------------------- | ----------------------- | +| `name` | *str* | :heavy_check_mark: | Data provider for the knowledge result. | Encyclopedia Britannica | +| `source_description` | *Optional[str]* | :heavy_minus_sign: | Description of the provider. | General knowledge | + +## Notes + +These are credits rather than citations: each entry names a provider and +carries no URL. Read them off a result with +`[credit.name for credit in card.attribution]`. diff --git a/docs/models/knowledgeresult.md b/docs/models/knowledgeresult.md new file mode 100644 index 0000000..0d0dabe --- /dev/null +++ b/docs/models/knowledgeresult.md @@ -0,0 +1,67 @@ +# KnowledgeResult + +A single knowledge result. `type` identifies the kind of result and determines which fields it populates. `type`, `title`, and `attribution` are required on every kind. + +For `type: answer`, the only kind currently returned, `description` is required and `as_of` is optional. + +## Example Usage + +```python +import os +from youdotcom import You + +with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: + res = you.search(query="what is the capital of France", knowledge="core") + + for card in res.results.knowledge or []: + print(card.type, card.title) + print(card.description) + print([credit.name for credit in card.attribution]) +``` + +Knowledge results arrive in the response rather than being constructed by the +caller. Given a raw payload, `KnowledgeResult.model_validate(data)` builds one +from a dict matching `KnowledgeResultTypedDict`. + +## Fields + +| Field | Type | Required | Description | Example | +| ------------- | ---------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------- | +| `type` | *str* | :heavy_check_mark: | The kind of knowledge result retrieved. `answer` is the only value currently returned. Ignore a value you do not recognize rather than failing on it, since a new kind may populate a different set of fields. | answer | +| `title` | *str* | :heavy_check_mark: | The title of the knowledge result. | Paris is the capital of France | +| `attribution` | List[[models.KnowledgeAttribution](../models/knowledgeattribution.md)] | :heavy_check_mark: | Display credit for the data behind the result. These are credits rather than citations: each entry names a provider and carries no URL. | | +| `description` | *Optional[str]* | :heavy_minus_sign: | Description of the knowledge result, drawn from proprietary licensed data. Required on `type: answer` results. | Paris has been the capital of France… | +| `as_of` | *Optional[str]* | :heavy_minus_sign: | The date the result's underlying data covers, as `YYYY-MM-DD`. Optional, and omitted when the provider reports no date. | 2026-04-26 | + +## Notes + +### The `results.knowledge` key is omitted, not empty + +Knowledge results are limited to those relevant to the query. When none are +relevant the API omits `results.knowledge` entirely rather than returning an +empty array, so `response.results.knowledge` is `None` — check for `None` +before iterating. + +```python +for card in res.results.knowledge or []: + print(card.title) +``` + +### `count` does not cap knowledge + +`count` sets the maximum for the web and news sections. Knowledge has its own +limit of up to 25 results, so `count=1` can still return several knowledge +results. + +### `type` is a plain string + +`type` is modeled as `str` rather than an enum so an unrecognized value parses +instead of raising. A new kind of knowledge result may populate a different +set of fields, so ignore a `type` you do not recognize rather than failing on +it. + +### `as_of` is a string, not a date + +`as_of` stays a `str` in `YYYY-MM-DD` form. Parse it with +`datetime.strptime(card.as_of, "%Y-%m-%d")` when you need a date object, and +expect `None` when the provider reports no date. diff --git a/docs/models/results.md b/docs/models/results.md index 342f0ea..f1f4007 100644 --- a/docs/models/results.md +++ b/docs/models/results.md @@ -3,7 +3,8 @@ ## Fields -| Field | Type | Required | Description | -| -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | -| `web` | List[[models.WebResult](../models/webresult.md)] | :heavy_minus_sign: | N/A | -| `news` | List[[models.NewsResult](../models/newsresult.md)] | :heavy_minus_sign: | N/A | \ No newline at end of file +| Field | Type | Required | Description | +| -------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `web` | List[[models.WebResult](../models/webresult.md)] | :heavy_minus_sign: | N/A | +| `news` | List[[models.NewsResult](../models/newsresult.md)] | :heavy_minus_sign: | N/A | +| `knowledge` | Optional[List[[models.KnowledgeResult](../models/knowledgeresult.md)]] | :heavy_minus_sign: | Results backed by licensed data providers. Up to 25 are returned, limited to those relevant to the query. When none are relevant the key is omitted rather than returned as an empty array. | \ No newline at end of file diff --git a/docs/models/searchrequestbody.md b/docs/models/searchrequestbody.md index 91fca65..54089c3 100644 --- a/docs/models/searchrequestbody.md +++ b/docs/models/searchrequestbody.md @@ -12,6 +12,7 @@ | `country` | [Optional[models.Country]](../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | +| `knowledge` | [Optional[models.Knowledge]](../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/docs/sdks/search/README.md b/docs/sdks/search/README.md index 28d669d..cdf4b6f 100644 --- a/docs/sdks/search/README.md +++ b/docs/sdks/search/README.md @@ -48,6 +48,7 @@ with You( | `country` | [Optional[models.Country]](../../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | +| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index adbba12..3f67aa9 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -263,6 +263,7 @@ with You( | `country` | [Optional[models.Country]](../../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | +| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/examples/api-example-calls.py b/examples/api-example-calls.py index 4aaad63..63dbf83 100755 --- a/examples/api-example-calls.py +++ b/examples/api-example-calls.py @@ -392,6 +392,40 @@ def search_request_with_boost(): print(f" - {result.title or 'Untitled'}: {result.url}") +def search_request_with_knowledge(): + """ + Search API: use `knowledge="core"` to add knowledge cards backed by + licensed data providers (encyclopedias, market-data firms, reference + publishers). They arrive in their own `results.knowledge` section. + + The section is omitted entirely when nothing relevant is found, so + `results.knowledge` is `None` rather than an empty list. `count` caps the + web and news sections, not knowledge. + """ + print("\n🚀 Running Search Request (knowledge)...\n") + + assert you is not None, "SDK client not initialized" + + results = you.search( + query="what is the capital of France", + knowledge="core", + ) + + print("Knowledge results:") + if results.results and results.results.knowledge: + for card in results.results.knowledge: + print(f" - [{card.type}] {card.title}") + if card.description: + preview = card.description[:120].replace("\n", " ") + print(f" {preview}...") + if card.as_of: + print(f" Data as of: {card.as_of}") + credits = ", ".join(credit.name for credit in card.attribution) + print(f" Credit: {credits}") + else: + print("No knowledge results found") + + def content_request_with_max_age(): """ Contents API: use `max_age` to control cache freshness (in seconds). @@ -420,6 +454,7 @@ def content_request_with_max_age(): {"name": "Search Request (extraction)", "fn": search_request}, {"name": "Search Request (deprecated livecrawl)", "fn": search_request_livecrawl_legacy}, {"name": "Search Request (boost_domains)", "fn": search_request_with_boost}, + {"name": "Search Request (knowledge)", "fn": search_request_with_knowledge}, {"name": "Content Request", "fn": content_request}, {"name": "Content Request (max_age)", "fn": content_request_with_max_age}, {"name": "Research Request", "fn": research_request}, diff --git a/pyproject.toml b/pyproject.toml index 236ecd1..c3e29e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "youdotcom" -version = "3.4.0" +version = "3.5.0" description = "The official You.com Python SDK." authors = [{ name = "You.com" },] readme = "README.md" diff --git a/scripts/check_drift.py b/scripts/check_drift.py index d15baa2..1c40d8f 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -46,6 +46,16 @@ ("GET", "/v1/search"), } +# Nested response fields the SDK models don't define yet. These predate the +# response check learning to recurse (it previously compared top-level fields +# only, so drift inside a nested object was invisible). Keyed by +# (spec name, dotted field path from the response root). An entry that goes +# stale — the SDK catches up — is reported rather than silently ignored. +KNOWN_RESPONSE_GAPS = { + ("answer", "results.web"): {"description", "thumbnail_url"}, + ("finance-research", "output.sources"): {"snippets"}, +} + # Map (method, path) -> SDK method name. # {task_id} and {task_id}/stream are handled by helpers, not direct methods. EXPECTED_ENDPOINTS = { @@ -77,6 +87,7 @@ ("web-search", "Country", "youdotcom.models", "Country"), ("answer", "Country", "youdotcom.models", "Country"), ("research", "Country", "youdotcom.models", "Country"), + ("web-search", "Knowledge", "youdotcom.models", "Knowledge"), ] # SDK-internal parameters that aren't API params (excluded from drift comparison). @@ -387,8 +398,116 @@ def check_request_params(specs: dict[str, dict[str, Any]]) -> list[str]: return warnings +def _resolve_schema(schema: dict[str, Any], spec: dict[str, Any]) -> dict[str, Any]: + """Follow ``$ref`` and array ``items`` down to the underlying object schema.""" + seen: set[str] = set() + while True: + if "$ref" in schema: + ref = schema["$ref"] + if ref in seen: + return {} + seen.add(ref) + schema = _resolve_ref(ref, spec) + continue + if schema.get("type") == "array" and isinstance(schema.get("items"), dict): + schema = schema["items"] + continue + return schema + + +def _nested_model(annotation: Any) -> Any: + """Return the pydantic model a field annotation points at, or ``None``. + + Unwraps ``Optional[...]`` and ``List[...]`` so nested response objects + more than one level down are still compared. + """ + args = getattr(annotation, "__args__", None) + if args is not None: + for arg in args: + found = _nested_model(arg) + if found is not None: + return found + return None + return annotation if hasattr(annotation, "model_fields") else None + + +def _compare_response_fields( + schema: dict[str, Any], + model: Any, + path: str, + spec: dict[str, Any], + warnings: list[str], + visited: set[Any], + spec_name: str, + field_path: str, +) -> None: + """Compare a spec object schema against a model, recursing into nested objects. + + A top-level-only comparison misses drift inside nested response objects: + a new ``results.knowledge`` array is invisible when ``results`` itself is + unchanged. + """ + if model in visited: + return + visited.add(model) + + schema = _resolve_schema(schema, spec) + props = schema.get("properties") + if not props: + # Nothing to compare against — a `oneOf` union or an unresolvable ref. + # Bail rather than reporting every SDK field as absent from the spec. + return + model_fields = set(model.model_fields.keys()) + + missing_in_sdk = set(props) - model_fields + missing_in_spec = model_fields - set(props) + + known = KNOWN_RESPONSE_GAPS.get((spec_name, field_path), set()) + stale = known - missing_in_sdk + if stale: + warnings.append( + f"[response] {path}: KNOWN_RESPONSE_GAPS entry {stale} is stale — " + f"the SDK model now defines it, so remove the entry" + ) + missing_in_sdk -= known + + if missing_in_sdk: + warnings.append( + f"[response] {path}: spec has fields {missing_in_sdk} " + f"which SDK model doesn't have" + ) + if missing_in_spec: + warnings.append( + f"[response] {path}: SDK model has fields {missing_in_spec} " + f"which spec doesn't define" + ) + + for prop_name, prop_schema in props.items(): + if prop_name not in model_fields: + continue + child_model = _nested_model(model.model_fields[prop_name].annotation) + if child_model is None: + continue + if "properties" in _resolve_schema(prop_schema, spec): + _compare_response_fields( + prop_schema, + child_model, + f"{path}.{prop_name}", + spec, + warnings, + visited, + spec_name, + f"{field_path}.{prop_name}" if field_path else prop_name, + ) + + def check_response_schemas(specs: dict[str, dict[str, Any]]) -> list[str]: - """Check that spec 200 response schema fields match SDK model fields.""" + """Check that spec 200 response schema fields match SDK model fields. + + Endpoints with a single response model are compared recursively, so fields + nested inside response objects are checked too. Endpoints whose response + can be one of several models fall back to a flat union comparison. + """ warnings: list[str] = [] for check in SCHEMA_CHECKS: @@ -408,6 +527,27 @@ def check_response_schemas(specs: dict[str, dict[str, Any]]) -> list[str]: if not schema: continue + model_names = check["sdk_response_models"] + # Recurse only when the response resolves to a single object schema. + # A `oneOf` union resolves to no properties, so it keeps the flat + # comparison below rather than being mistaken for an empty schema. + if len(model_names) == 1 and _resolve_schema(schema, spec).get("properties"): + import importlib + + mod = importlib.import_module("youdotcom.models") + model = getattr(mod, model_names[0]) + _compare_response_fields( + schema, + model, + f"{spec_name} {method} {path} {model_names[0]}", + spec, + warnings, + set(), + spec_name, + "", + ) + continue + spec_fields = _get_schema_properties(schema, spec) sdk_fields = _get_sdk_model_fields(check["sdk_response_models"]) diff --git a/src/youdotcom/_shims.py b/src/youdotcom/_shims.py index 3d8fd8d..b9444fa 100644 --- a/src/youdotcom/_shims.py +++ b/src/youdotcom/_shims.py @@ -58,6 +58,7 @@ def __call__( country: Optional[str] = None, language: OptionalNullable[str] = UNSET, safesearch: Optional[str] = None, + knowledge: Optional[str] = None, livecrawl: Optional[str] = None, livecrawl_formats: Optional[Iterable[str]] = None, extraction: Optional[Union[models.Extraction, Mapping[str, Any]]] = None, @@ -78,6 +79,7 @@ def __call__( country=country, language=language, safesearch=safesearch, + knowledge=knowledge, livecrawl=livecrawl, livecrawl_formats=livecrawl_formats, extraction=extraction, @@ -101,6 +103,7 @@ def unified( country: Optional[str] = None, language: OptionalNullable[str] = UNSET, safesearch: Optional[str] = None, + knowledge: Optional[str] = None, livecrawl: Optional[str] = None, livecrawl_formats: Optional[Iterable[str]] = None, extraction: Optional[Union[models.Extraction, Mapping[str, Any]]] = None, @@ -123,6 +126,7 @@ def unified( country=country, language=language, safesearch=safesearch, + knowledge=knowledge, livecrawl=livecrawl, livecrawl_formats=livecrawl_formats, extraction=extraction, @@ -146,6 +150,7 @@ async def unified_async( country: Optional[str] = None, language: OptionalNullable[str] = UNSET, safesearch: Optional[str] = None, + knowledge: Optional[str] = None, livecrawl: Optional[str] = None, livecrawl_formats: Optional[Iterable[str]] = None, extraction: Optional[Union[models.Extraction, Mapping[str, Any]]] = None, @@ -168,6 +173,7 @@ async def unified_async( country=country, language=language, safesearch=safesearch, + knowledge=knowledge, livecrawl=livecrawl, livecrawl_formats=livecrawl_formats, extraction=extraction, diff --git a/src/youdotcom/_version.py b/src/youdotcom/_version.py index e946998..b2a33e2 100644 --- a/src/youdotcom/_version.py +++ b/src/youdotcom/_version.py @@ -2,7 +2,7 @@ import importlib.metadata __title__: str = "youdotcom" -__version__: str = "3.4.0" +__version__: str = "3.5.0" __openapi_doc_version__: str = "1.0.0" try: diff --git a/src/youdotcom/models/__init__.py b/src/youdotcom/models/__init__.py index 8220763..aa7b6fe 100644 --- a/src/youdotcom/models/__init__.py +++ b/src/youdotcom/models/__init__.py @@ -57,6 +57,12 @@ GetResearchTaskRequest, GetResearchTaskRequestTypedDict, ) + from .knowledge import Knowledge + from .knowledgeattribution import ( + KnowledgeAttribution, + KnowledgeAttributionTypedDict, + ) + from .knowledgeresult import KnowledgeResult, KnowledgeResultTypedDict from .language import Language from .livecrawl import LiveCrawl from .livecrawlformats import LiveCrawlFormats @@ -178,6 +184,11 @@ "FreshnessValueTypedDict", "GetResearchTaskRequest", "GetResearchTaskRequestTypedDict", + "Knowledge", + "KnowledgeAttribution", + "KnowledgeAttributionTypedDict", + "KnowledgeResult", + "KnowledgeResultTypedDict", "Language", "LiveCrawl", "LiveCrawlFormats", @@ -278,6 +289,11 @@ "FreshnessValueTypedDict": ".freshnessvalue", "GetResearchTaskRequest": ".getresearchtaskop", "GetResearchTaskRequestTypedDict": ".getresearchtaskop", + "Knowledge": ".knowledge", + "KnowledgeAttribution": ".knowledgeattribution", + "KnowledgeAttributionTypedDict": ".knowledgeattribution", + "KnowledgeResult": ".knowledgeresult", + "KnowledgeResultTypedDict": ".knowledgeresult", "Language": ".language", "LiveCrawl": ".livecrawl", "LiveCrawlFormats": ".livecrawlformats", diff --git a/src/youdotcom/models/knowledge.py b/src/youdotcom/models/knowledge.py new file mode 100644 index 0000000..9a76e53 --- /dev/null +++ b/src/youdotcom/models/knowledge.py @@ -0,0 +1,10 @@ + + +from __future__ import annotations +from enum import Enum + + +class Knowledge(str, Enum): + r"""Requests knowledge results alongside web and news search.""" + + CORE = "core" diff --git a/src/youdotcom/models/knowledgeattribution.py b/src/youdotcom/models/knowledgeattribution.py new file mode 100644 index 0000000..ec2ef20 --- /dev/null +++ b/src/youdotcom/models/knowledgeattribution.py @@ -0,0 +1,38 @@ + + +from __future__ import annotations +from pydantic import model_serializer +from typing import Optional +from typing_extensions import NotRequired, TypedDict +from youdotcom.types import BaseModel, UNSET_SENTINEL + + +class KnowledgeAttributionTypedDict(TypedDict): + name: str + r"""Data provider for the knowledge result.""" + source_description: NotRequired[str] + r"""Description of the provider.""" + + +class KnowledgeAttribution(BaseModel): + name: str + r"""Data provider for the knowledge result.""" + + source_description: Optional[str] = None + r"""Description of the provider.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["source_description"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m diff --git a/src/youdotcom/models/knowledgeresult.py b/src/youdotcom/models/knowledgeresult.py new file mode 100644 index 0000000..dd5f198 --- /dev/null +++ b/src/youdotcom/models/knowledgeresult.py @@ -0,0 +1,64 @@ + + +from __future__ import annotations +from .knowledgeattribution import KnowledgeAttribution, KnowledgeAttributionTypedDict +from pydantic import model_serializer +from typing import List, Optional +from typing_extensions import NotRequired, TypedDict +from youdotcom.types import BaseModel, UNSET_SENTINEL + + +class KnowledgeResultTypedDict(TypedDict): + r"""A single knowledge result. `type` identifies the kind of result and determines which fields it populates. `type`, `title`, and `attribution` are required on every kind. + + For `type: answer`, the only kind currently returned, `description` is required and `as_of` is optional. + """ + + type: str + r"""The kind of knowledge result retrieved. `answer` is the only value currently returned. Ignore a value you do not recognize rather than failing on it, since a new kind may populate a different set of fields.""" + title: str + r"""The title of the knowledge result.""" + attribution: List[KnowledgeAttributionTypedDict] + r"""Display credit for the data behind the result. These are credits rather than citations: each entry names a provider and carries no URL.""" + description: NotRequired[str] + r"""Description of the knowledge result, drawn from proprietary licensed data. Required on `type: answer` results.""" + as_of: NotRequired[str] + r"""The date the result's underlying data covers, as `YYYY-MM-DD`. Optional, and omitted when the provider reports no date.""" + + +class KnowledgeResult(BaseModel): + r"""A single knowledge result. `type` identifies the kind of result and determines which fields it populates. `type`, `title`, and `attribution` are required on every kind. + + For `type: answer`, the only kind currently returned, `description` is required and `as_of` is optional. + """ + + type: str + r"""The kind of knowledge result retrieved. `answer` is the only value currently returned. Ignore a value you do not recognize rather than failing on it, since a new kind may populate a different set of fields.""" + + title: str + r"""The title of the knowledge result.""" + + attribution: List[KnowledgeAttribution] + r"""Display credit for the data behind the result. These are credits rather than citations: each entry names a provider and carries no URL.""" + + description: Optional[str] = None + r"""Description of the knowledge result, drawn from proprietary licensed data. Required on `type: answer` results.""" + + as_of: Optional[str] = None + r"""The date the result's underlying data covers, as `YYYY-MM-DD`. Optional, and omitted when the provider reports no date.""" + + @model_serializer(mode="wrap") + def serialize_model(self, handler): + optional_fields = set(["description", "as_of"]) + serialized = handler(self) + m = {} + + for n, f in type(self).model_fields.items(): + k = f.alias or n + val = serialized.get(k, serialized.get(n)) + + if val != UNSET_SENTINEL: + if val is not None or k not in optional_fields: + m[k] = val + + return m diff --git a/src/youdotcom/models/searchrequestbody.py b/src/youdotcom/models/searchrequestbody.py index 46a21fe..e880925 100644 --- a/src/youdotcom/models/searchrequestbody.py +++ b/src/youdotcom/models/searchrequestbody.py @@ -4,6 +4,7 @@ from .country import Country from .extraction import Extraction, ExtractionTypedDict from .freshnessvalue import FreshnessValue, FreshnessValueTypedDict +from .knowledge import Knowledge from .language import Language from .livecrawl import LiveCrawl from .livecrawlformats import LiveCrawlFormats @@ -32,6 +33,8 @@ class SearchRequestBodyTypedDict(TypedDict): r"""The language of the web results that will be returned (BCP 47 format).""" safesearch: NotRequired[SafeSearch] r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" + knowledge: NotRequired[Knowledge] + r"""Requests knowledge results alongside web and news search.""" livecrawl: NotRequired[LiveCrawl] r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" livecrawl_formats: NotRequired[List[LiveCrawlFormats]] @@ -87,6 +90,9 @@ class SearchRequestBody(BaseModel): safesearch: Optional[SafeSearch] = None r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" + knowledge: Optional[Knowledge] = None + r"""Requests knowledge results alongside web and news search.""" + livecrawl: Optional[LiveCrawl] = None r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" @@ -132,6 +138,7 @@ def serialize_model(self, handler): "country", "language", "safesearch", + "knowledge", "livecrawl", "livecrawl_formats", "include_domains", diff --git a/src/youdotcom/models/searchresponse.py b/src/youdotcom/models/searchresponse.py index e17262f..72f120c 100644 --- a/src/youdotcom/models/searchresponse.py +++ b/src/youdotcom/models/searchresponse.py @@ -1,6 +1,7 @@ from __future__ import annotations +from .knowledgeresult import KnowledgeResult, KnowledgeResultTypedDict from .newsresult import NewsResult, NewsResultTypedDict from .searchmetadata import SearchMetadata, SearchMetadataTypedDict from .webresult import WebResult, WebResultTypedDict @@ -13,6 +14,8 @@ class ResultsTypedDict(TypedDict): web: NotRequired[List[WebResultTypedDict]] news: NotRequired[List[NewsResultTypedDict]] + knowledge: NotRequired[List[KnowledgeResultTypedDict]] + r"""Results backed by licensed data providers. Up to 25 are returned, limited to those relevant to the query. When none are relevant the key is omitted rather than returned as an empty array.""" class Results(BaseModel): @@ -20,9 +23,12 @@ class Results(BaseModel): news: Optional[List[NewsResult]] = None + knowledge: Optional[List[KnowledgeResult]] = None + r"""Results backed by licensed data providers. Up to 25 are returned, limited to those relevant to the query. When none are relevant the key is omitted rather than returned as an empty array.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["web", "news"]) + optional_fields = set(["web", "news", "knowledge"]) serialized = handler(self) m = {} diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 53ef1b9..5743e02 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -42,10 +42,10 @@ def _upper(value: Any) -> Any: def _lower(value: Any) -> Any: """Normalize a plain-string enum value to its lowercase spelling. - Used for ``safesearch``, ``livecrawl``, and ``freshness``, whose enum - members are lowercase (``"STRICT"`` -> ``"strict"``). Date-range freshness - values are unaffected apart from the ``to`` separator, which the API - expects in lowercase anyway. + Used for ``safesearch``, ``knowledge``, ``livecrawl``, and ``freshness``, + whose enum members are lowercase (``"STRICT"`` -> ``"strict"``). Date-range + freshness values are unaffected apart from the ``to`` separator, which the + API expects in lowercase anyway. """ return value.lower() if isinstance(value, str) else value @@ -66,6 +66,7 @@ def _build_search_request( country: Optional[str], language: OptionalNullable[str], safesearch: Optional[str], + knowledge: Optional[str], livecrawl: Optional[str], livecrawl_formats: Optional[Iterable[str]], extraction: Optional[Union[models.Extraction, Mapping[str, Any]]], @@ -147,6 +148,7 @@ def _build_search_request( offset=offset, country=_upper(country), safesearch=_lower(safesearch), + knowledge=_lower(knowledge), livecrawl=_lower(livecrawl), livecrawl_formats=utils.unmarshal( _lower_each(livecrawl_formats), Optional[List[models.LiveCrawlFormats]] @@ -995,6 +997,7 @@ def _search_impl( country: Optional[str] = None, language: OptionalNullable[str] = UNSET, safesearch: Optional[str] = None, + knowledge: Optional[str] = None, livecrawl: Optional[str] = None, livecrawl_formats: Optional[Iterable[str]] = None, extraction: Optional[Union[models.Extraction, Mapping[str, Any]]] = None, @@ -1010,9 +1013,9 @@ def _search_impl( r"""Search via POST /v1/search. Enum-typed parameters (``country``, ``language``, ``safesearch``, - ``livecrawl``, ``livecrawl_formats``, ``freshness``) accept plain - strings in any case -- the SDK normalizes them to the casing the API - expects, so callers don't need to import enum classes. + ``knowledge``, ``livecrawl``, ``livecrawl_formats``, ``freshness``) + accept plain strings in any case -- the SDK normalizes them to the + casing the API expects, so callers don't need to import enum classes. ``livecrawl`` and ``livecrawl_formats`` are deprecated; prefer ``extraction``. The two are mutually exclusive -- passing both @@ -1030,6 +1033,11 @@ def _search_impl( :param language: BCP 47 language code. Omit the argument to use the API default (``"en"``); pass ``None`` to send no language at all. :param safesearch: ``"strict"``, ``"moderate"``, or ``"off"``. + :param knowledge: ``"core"`` -- requests knowledge results alongside + web and news search. Omit to skip them. It is the only value the + API accepts; anything else raises + :class:`pydantic.ValidationError` locally, mirroring the server's + ``422``. :param livecrawl: deprecated. ``"web"``, ``"news"``, or ``"all"``. Use ``extraction`` instead. Mutually exclusive with ``extraction``. :param livecrawl_formats: deprecated. ``["html"]``, ``["markdown"]``, @@ -1075,6 +1083,7 @@ def _search_impl( country=country, language=language, safesearch=safesearch, + knowledge=knowledge, livecrawl=livecrawl, livecrawl_formats=livecrawl_formats, extraction=extraction, @@ -1171,6 +1180,7 @@ async def search_async( country: Optional[str] = None, language: OptionalNullable[str] = UNSET, safesearch: Optional[str] = None, + knowledge: Optional[str] = None, livecrawl: Optional[str] = None, livecrawl_formats: Optional[Iterable[str]] = None, extraction: Optional[Union[models.Extraction, Mapping[str, Any]]] = None, @@ -1207,6 +1217,7 @@ async def search_async( country=country, language=language, safesearch=safesearch, + knowledge=knowledge, livecrawl=livecrawl, livecrawl_formats=livecrawl_formats, extraction=extraction, diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py new file mode 100644 index 0000000..d5645ea --- /dev/null +++ b/tests/test_knowledge.py @@ -0,0 +1,266 @@ +"""Tests for the ``knowledge`` parameter and knowledge results on ``you.search``. + +Locks the contract for the Knowledge launch (``POST /v1/search``): + +- ``knowledge`` is a new enum-typed request parameter. ``"core"`` is the only + value the API accepts; anything else is rejected server-side with ``422``. +- The parameter is normalized to lowercase like the other enum-typed search + parameters, and is omitted from the request body when not supplied. +- ``results.knowledge`` is a list of ``KnowledgeResult``. ``type``, ``title``, + and ``attribution`` are always present; ``description`` and ``as_of`` are + optional. ``source_description`` inside an attribution entry is optional. +- The ``results.knowledge`` key is omitted entirely when no knowledge results + are relevant, so the parsed attribute is ``None`` rather than ``[]``. +- ``KnowledgeResult.type`` is modeled as a plain string: an unrecognized value + parses instead of raising, since a new kind may arrive later. +""" + +import json +from contextlib import contextmanager + +import httpx +import pytest +from pydantic import ValidationError + +from youdotcom import You, models +from youdotcom.models import Knowledge, KnowledgeAttribution, KnowledgeResult + + +@contextmanager +def _capture(response_body: dict): + """Yield ``(You, captured)`` over a mock transport returning ``response_body``.""" + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps(response_body), + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + try: + with You(api_key_auth="k", server_url="http://mock.local", client=client) as you: + yield you, captured + finally: + client.close() + + +_EMPTY = {"results": {"web": []}, "metadata": {"query": "q"}} + + +def _search_body(**kwargs) -> dict: + """Run one synchronous search; return the JSON body that went over the wire.""" + with _capture(_EMPTY) as (you, captured): + you.search(query="q", **kwargs) + return captured["body"] + + +def _parse(results: dict) -> models.SearchResponse: + """Parse a ``results`` mapping through the response model.""" + return models.SearchResponse.model_validate( + {"results": results, "metadata": {"query": "q"}} + ) + + +# A knowledge result shaped like what prod returns today. +_ANSWER = { + "type": "answer", + "title": "Paris is the capital of France", + "description": "Paris has been the capital of France since the 10th century.", + "as_of": "2026-09-01", + "attribution": [ + { + "name": "Encyclopedia Britannica", + "source_description": "General knowledge", + } + ], +} + + +# --------------------------------------------------------------------------- +# Enum contract +# --------------------------------------------------------------------------- + + +class TestKnowledgeEnum: + def test_core_member(self): + assert Knowledge.CORE.value == "core" + + def test_core_is_the_only_member(self): + """``knowledge=detailed`` is not public yet — shipping it here would + let callers send a value the API rejects with ``422``.""" + assert [m.value for m in Knowledge] == ["core"] + + +# --------------------------------------------------------------------------- +# Request wiring +# --------------------------------------------------------------------------- + + +class TestKnowledgeRequest: + def test_knowledge_lands_on_wire(self): + assert _search_body(knowledge="core")["knowledge"] == "core" + + def test_knowledge_lowercased(self): + assert _search_body(knowledge="CORE")["knowledge"] == "core" + + def test_knowledge_accepts_enum_instance(self): + assert _search_body(knowledge=Knowledge.CORE)["knowledge"] == "core" + + def test_knowledge_omitted_by_default(self): + assert "knowledge" not in _search_body() + + def test_knowledge_does_not_disturb_neighbours(self): + body = _search_body(knowledge="core", count=5, safesearch="off") + assert body["knowledge"] == "core" + assert body["count"] == 5 + assert body["safesearch"] == "off" + + def test_request_body_model_serializes_enum(self): + body = models.SearchRequestBody(query="q", knowledge=Knowledge.CORE) + assert body.model_dump(mode="json")["knowledge"] == "core" + + def test_invalid_value_raises_locally(self): + """``knowledge`` is enum-typed on the request body, so a value the API + would reject with ``422`` raises ``ValidationError`` before any + request is sent -- the same local-mirrors-server pattern as + ``extraction``.""" + with pytest.raises(ValidationError): + _search_body(knowledge="detailed") + + +# --------------------------------------------------------------------------- +# Response parsing +# --------------------------------------------------------------------------- + + +class TestKnowledgeResponse: + def test_parses_full_answer(self): + resp = _parse({"knowledge": [_ANSWER]}) + assert resp.results.knowledge is not None + kr = resp.results.knowledge[0] + assert kr.type == "answer" + assert kr.title == "Paris is the capital of France" + assert kr.description.startswith("Paris has been") + assert kr.as_of == "2026-09-01" + assert kr.attribution[0].name == "Encyclopedia Britannica" + assert kr.attribution[0].source_description == "General knowledge" + + def test_optional_fields_default_to_none(self): + kr = KnowledgeResult.model_validate( + {"type": "answer", "title": "t", "attribution": [{"name": "n"}]} + ) + assert kr.description is None + assert kr.as_of is None + assert kr.attribution[0].source_description is None + + def test_optional_fields_omitted_on_round_trip(self): + """Absent optionals stay absent rather than serializing as null.""" + kr = KnowledgeResult.model_validate( + {"type": "answer", "title": "t", "description": "d", "attribution": [{"name": "n"}]} + ) + dumped = kr.model_dump(mode="json") + assert dumped == { + "type": "answer", + "title": "t", + "attribution": [{"name": "n"}], + "description": "d", + } + + def test_attribution_omits_absent_source_description(self): + dumped = KnowledgeAttribution(name="n").model_dump(mode="json") + assert dumped == {"name": "n"} + + def test_unknown_type_parses(self): + """Forward compat: the spec says to ignore an unrecognized ``type`` + rather than fail, since a new kind may populate different fields.""" + kr = KnowledgeResult.model_validate( + {"type": "some_future_kind", "title": "t", "attribution": [{"name": "n"}]} + ) + assert kr.type == "some_future_kind" + + def test_missing_knowledge_key_is_none(self): + """Prod omits ``results.knowledge`` when nothing is relevant.""" + assert _parse({"web": []}).results.knowledge is None + + def test_multiple_results_preserved(self): + resp = _parse({"knowledge": [_ANSWER, {**_ANSWER, "title": "second"}]}) + assert [r.title for r in resp.results.knowledge] == [ + "Paris is the capital of France", + "second", + ] + + def test_knowledge_alongside_news(self): + resp = _parse({"knowledge": [_ANSWER], "news": []}) + assert resp.results.knowledge is not None + assert resp.results.news == [] + + +# --------------------------------------------------------------------------- +# End to end through the SDK +# --------------------------------------------------------------------------- + + +class TestKnowledgeEndToEnd: + def test_search_returns_parsed_knowledge(self): + with _capture({"results": {"knowledge": [_ANSWER]}}) as (you, captured): + resp = you.search(query="what is the capital of France", knowledge="core") + assert captured["body"]["knowledge"] == "core" + assert resp.results.knowledge[0].title == "Paris is the capital of France" + + +# --------------------------------------------------------------------------- +# Async + deprecated-spelling parity +# --------------------------------------------------------------------------- + + +class TestKnowledgeParity: + @pytest.mark.asyncio + async def test_search_async_sends_knowledge(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps({"results": {"knowledge": [_ANSWER]}}), + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as ac: + async with You( + api_key_auth="k", server_url="http://mock.local", async_client=ac + ) as you: + resp = await you.search_async(query="q", knowledge="core") + + assert captured["body"]["knowledge"] == "core" + assert resp.results.knowledge[0].type == "answer" + + def test_deprecated_unified_passes_knowledge(self): + with _capture(_EMPTY) as (you, captured): + with pytest.warns(DeprecationWarning): + you.search.unified(query="q", knowledge="core") + assert captured["body"]["knowledge"] == "core" + + @pytest.mark.asyncio + async def test_deprecated_unified_async_passes_knowledge(self): + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps(_EMPTY), + ) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as ac: + async with You( + api_key_auth="k", server_url="http://mock.local", async_client=ac + ) as you: + with pytest.warns(DeprecationWarning): + await you.search.unified_async(query="q", knowledge="core") + + assert captured["body"]["knowledge"] == "core" diff --git a/tests/test_live.py b/tests/test_live.py index 1d61126..90176cb 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -16,6 +16,7 @@ """ import os +from datetime import datetime import httpx import pytest @@ -29,6 +30,7 @@ ExtractionMode, ExtractionSource, Freshness, + Knowledge, LiveCrawl, LiveCrawlFormats, SafeSearch, @@ -389,6 +391,85 @@ def test_full_page_extraction_source_fetch(self, you_client): ) +@requires_api_key +class TestLiveSearchKnowledge: + """Live tests for the ``knowledge`` parameter on ``POST /v1/search``. + + ``KNOWLEDGE_QUERY`` is verified against prod to return knowledge results. + Coverage is data-dependent, so these assert the shape of what comes back + rather than any specific card. + + These deliberately make no assertion about ``results.web``. Knowledge + coverage varies by query and the web section is not part of the knowledge + contract, so coupling the two would make these tests flaky for reasons + unrelated to this surface. + """ + + KNOWLEDGE_QUERY = "what is the capital of France" + + def test_knowledge_core_returns_results(self, you_client): + with you_client as you: + res = you.search(query=self.KNOWLEDGE_QUERY, knowledge=Knowledge.CORE) + + assert res.results is not None + assert res.results.knowledge, ( + "Expected knowledge results for a query verified to have them; " + "server-side coverage may have changed" + ) + + def test_knowledge_accepts_plain_string(self, you_client): + """Enum-typed params take plain strings, like ``safesearch``.""" + with you_client as you: + res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core") + + assert res.results.knowledge + + def test_knowledge_result_shape(self, you_client): + with you_client as you: + res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core") + + assert res.results.knowledge + for kr in res.results.knowledge: + assert kr.type == "answer", "`answer` is the only kind returned today" + assert kr.title + # description is required on type=answer results + assert kr.description + assert kr.attribution, "attribution is required on every kind" + for credit in kr.attribution: + assert credit.name + # as_of is optional; when present it is a bare YYYY-MM-DD date + if kr.as_of is not None: + datetime.strptime(kr.as_of, "%Y-%m-%d") + + def test_knowledge_within_documented_cap(self, you_client): + """Up to 25 knowledge results. ``count`` caps the web/news sections, + not knowledge -- knowledge has its own limit.""" + with you_client as you: + res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core", count=1) + + assert res.results.knowledge + assert len(res.results.knowledge) <= 25 + + def test_knowledge_omitted_when_not_requested(self, you_client): + """Baseline calls omit ``results.knowledge`` entirely, so the parsed + attribute is ``None`` rather than an empty list.""" + with you_client as you: + res = you.search(query="Python programming language") + + assert res.results is not None + assert res.results.knowledge is None + + @pytest.mark.asyncio + async def test_search_async_knowledge(self, you_client): + async with you_client as you: + res = await you.search_async( + query=self.KNOWLEDGE_QUERY, knowledge="core" + ) + + assert res.results is not None + assert res.results.knowledge + + @requires_api_key class TestLiveContents: """Live tests for the Contents API.""" diff --git a/tests/test_param_normalization.py b/tests/test_param_normalization.py index cdc0365..a56acfa 100644 --- a/tests/test_param_normalization.py +++ b/tests/test_param_normalization.py @@ -3,7 +3,8 @@ The SDK advertises that enum-typed parameters accept plain strings in any case so callers never have to import an enum class. `country`/`language` normalize upward (their enum members are uppercase); `safesearch`, -`livecrawl`, `livecrawl_formats`, and `freshness` normalize downward. +`knowledge`, `livecrawl`, `livecrawl_formats`, and `freshness` normalize +downward. Also pins the three-way `language` contract, which is easy to break: @@ -19,7 +20,7 @@ import pytest from youdotcom import You -from youdotcom.models import Country, Language, LiveCrawl, SafeSearch +from youdotcom.models import Country, Knowledge, Language, LiveCrawl, SafeSearch _SEARCH_BODY = json.dumps({"results": {"web": []}}) @@ -83,6 +84,10 @@ class TestLowercaseParams: def test_safesearch_normalizes_to_lower(self, value): assert _search_body(safesearch=value)["safesearch"] == "strict" + @pytest.mark.parametrize("value", ["core", "CORE", Knowledge.CORE]) + def test_knowledge_normalizes_to_lower(self, value): + assert _search_body(knowledge=value)["knowledge"] == "core" + @pytest.mark.parametrize("value", ["web", "WEB", LiveCrawl.WEB]) def test_livecrawl_normalizes_to_lower(self, value): assert _search_body(livecrawl=value)["livecrawl"] == "web" diff --git a/tests/test_performance.py b/tests/test_performance.py index e6e6fe4..eca2a14 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -569,6 +569,31 @@ def call(): if show_detailed: print_detailed_metrics(metrics) + # ---------------------------------------------------------------- + # Knowledge case: `knowledge="core"` adds a licensed-data section to + # the response, which changes payload size and response latency. + # ---------------------------------------------------------------- + + def test_search_with_knowledge_core(self, server_url, api_key, iterations, show_detailed): + """Search with knowledge="core" (adds the results.knowledge section).""" + client = create_timing_client("post_/v1/search") + + with You(server_url=server_url, client=client, api_key_auth=api_key, timeout_ms=90_000) as you: + def call(): + you.search( + query="what is the capital of France", + count=3, + knowledge="core", + server_url=server_url, + ) + + metrics = measure_sdk_call( + call, client, iterations, "Search: knowledge=core" + ) + ALL_METRICS.append(metrics) + if show_detailed: + print_detailed_metrics(metrics) + # ============================================================================ # Contents Endpoint Tests diff --git a/uv.lock b/uv.lock index 5362b2f..bc8321c 100644 --- a/uv.lock +++ b/uv.lock @@ -954,7 +954,7 @@ wheels = [ [[package]] name = "youdotcom" -version = "3.4.0" +version = "3.5.0" source = { editable = "." } dependencies = [ { name = "httpcore" }, From e64094aeed62e8588b1a7c8bbd3cf84c54655d1d Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 13:08:42 -0700 Subject: [PATCH 02/24] fix: address review findings on drift stale logic and live assertions (DX-835) Two of three review findings verified valid and fixed here. check_drift.py: the KNOWN_RESPONSE_GAPS staleness check compared against `missing_in_sdk`, so a field the spec *dropped* was reported as stale with the message "the SDK model now defines it" -- false, and it would fail a strict drift run for the wrong reason. Staleness now compares against the model's own fields (`known & model_fields`). Negative-tested four ways: spec-drops-field emits nothing, SDK-defines-field still reports stale, genuine new drift is still caught, and existing suppressions still hide the known gaps. The previous logic reproduced the false positive on the first case. test_live.py: test_knowledge_result_shape hard-asserted `type == "answer"` and required `description` on every result, contradicting the forward-compat design behind modeling `type` as a plain str. The spec states description is "Required on `type: answer` results" only, and that an unrecognized kind should be ignored rather than failed on. The test now asserts the fields the spec requires of every kind and scopes description to `type == "answer"`. Rejected: rewording the Knowledge docstring to drop "alongside web and news search". That sentence is verbatim from the published OpenAPI spec, which the model docstrings mirror by convention. The live API contradicting it is the server-side bug tracked separately; rewriting the SDK contract to match a launch-blocking bug would desync the SDK from the spec and docs. --- scripts/check_drift.py | 6 +++++- tests/test_live.py | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/scripts/check_drift.py b/scripts/check_drift.py index 1c40d8f..0a3bd3d 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -463,7 +463,11 @@ def _compare_response_fields( missing_in_spec = model_fields - set(props) known = KNOWN_RESPONSE_GAPS.get((spec_name, field_path), set()) - stale = known - missing_in_sdk + # Stale means the SDK model now defines the field, so the suppression no + # longer does anything. Compare against the model, not against what's + # missing: a field the spec dropped is neither missing nor defined, and + # must not be reported as stale. + stale = known & model_fields if stale: warnings.append( f"[response] {path}: KNOWN_RESPONSE_GAPS entry {stale} is stale — " diff --git a/tests/test_live.py b/tests/test_live.py index 90176cb..1f79d35 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -430,13 +430,17 @@ def test_knowledge_result_shape(self, you_client): assert res.results.knowledge for kr in res.results.knowledge: - assert kr.type == "answer", "`answer` is the only kind returned today" + # ``type`` is a plain str so an unrecognized future kind parses + # rather than raising; assert only what the spec requires of every + # kind instead of pinning ``answer`` as the sole value. + assert isinstance(kr.type, str) and kr.type assert kr.title - # description is required on type=answer results - assert kr.description assert kr.attribution, "attribution is required on every kind" for credit in kr.attribution: assert credit.name + # description is required on type=answer results only + if kr.type == "answer": + assert kr.description # as_of is optional; when present it is a bare YYYY-MM-DD date if kr.as_of is not None: datetime.strptime(kr.as_of, "%Y-%m-%d") From 01986d14c1f4776d9e9ac77d25fe92c4cd4b43bd Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 13:23:30 -0700 Subject: [PATCH 03/24] docs: make the README knowledge snippet copy-paste runnable (DX-835) The Knowledge results snippet used a bare `you`, so pasting it alone raised NameError, and it made a network call without timeout_ms. Its direct structural precedent -- `#### Page content extraction`, the same kind of new-search-param subsection at the same heading level -- is fully wrapped with imports, client construction, and timeout_ms=60_000. The bare-style snippets in this README are only the short top-level API-overview blurbs that follow the Quickstart. Now matches the extraction subsection. Verified by extracting the rendered snippet and running it verbatim against prod: prints knowledge card titles, descriptions, and attribution credits. --- README.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index a947acd..0ed574c 100644 --- a/README.md +++ b/README.md @@ -152,15 +152,18 @@ encyclopedias, market-data firms, reference publishers. They come back in their own section: ```python -res = you.search( - query="what is the capital of France", - knowledge="core", -) +import os +from youdotcom import You -for card in res.results.knowledge or []: - print(card.title) - print(card.description) - print([credit.name for credit in card.attribution]) +with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: + res = you.search( + query="what is the capital of France", + knowledge="core", + ) + for card in res.results.knowledge or []: + print(card.title) + print(card.description) + print([credit.name for credit in card.attribution]) ``` `"core"` is the only value the API accepts; anything else raises From 98c1fc40337e1ae90b3c928e868b1bb47117a587 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 14:28:10 -0700 Subject: [PATCH 04/24] docs: mark web and news as Optional in the Results type cells (DX-835) `Results.web` and `Results.news` are `Optional[List[...]]` in `searchresponse.py`, but their `docs/models/results.md` type cells read plain `List[...]`. The wrong cells pre-date this branch, but adding the `knowledge` row re-padded the table and re-emitted both lines, so they are changed lines in this diff. AGENTS.md ("Type cells in field tables come from the annotation") names `Optional[X]` vs `X` as a trap and requires eyeballing every row of a touched model page, not just the rows whose prose changed. Verified with an annotation-vs-docs comparison across all four touched model pages (25 rows): all match, including the new `knowledge` row. --- docs/models/results.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/models/results.md b/docs/models/results.md index f1f4007..a033b95 100644 --- a/docs/models/results.md +++ b/docs/models/results.md @@ -5,6 +5,6 @@ | Field | Type | Required | Description | | -------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `web` | List[[models.WebResult](../models/webresult.md)] | :heavy_minus_sign: | N/A | -| `news` | List[[models.NewsResult](../models/newsresult.md)] | :heavy_minus_sign: | N/A | +| `web` | Optional[List[[models.WebResult](../models/webresult.md)]] | :heavy_minus_sign: | N/A | +| `news` | Optional[List[[models.NewsResult](../models/newsresult.md)]] | :heavy_minus_sign: | N/A | | `knowledge` | Optional[List[[models.KnowledgeResult](../models/knowledgeresult.md)]] | :heavy_minus_sign: | Results backed by licensed data providers. Up to 25 are returned, limited to those relevant to the query. When none are relevant the key is omitted rather than returned as an empty array. | \ No newline at end of file From e8fa8d63854f74fce4c20d68aec533eceabffb94 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 14:46:28 -0700 Subject: [PATCH 05/24] fix: sync knowledge field docstrings and relax a live description assert (DX-835) Two findings from the second droid-review round, both verified valid. Cross-table identity: the `knowledge` description in docs/sdks/you/README.md, docs/sdks/search/README.md and docs/models/searchrequestbody.md carried the `"core"`-only / ValidationError / 25-result / `count` constraints, but both `SearchRequestBody` field docstrings stopped after the first sentence. AGENTS.md requires those surfaces to read identically. Expanded the docstrings rather than shrinking the tables, matching how `include_domains` and `extraction` already carry SDK-layer guidance past the spec sentence. The `Knowledge` enum class docstring stays spec-verbatim: it is not one of the four cross-table surfaces, `components.schemas.Knowledge.description` is exactly that sentence (re-fetched), and `SearchRequestBody.properties.knowledge` carries no description of its own. `FreshnessValue` and `LiveCrawlFormats` likewise have enum docstrings that differ from their field docstrings. Live assert: `assert kr.description` -> `assert kr.description is not None`. `description` is `Optional[str]`, so `is not None` is the real presence check; truthiness would also enforce non-empty, which AGENTS.md's "`is not None`, not truthiness" rule warns against for server-returned strings. `type`, `title` and `attribution[].name` keep truthiness deliberately: those are required `str` fields, where `is not None` is vacuous (pydantic already guarantees it) and non-empty is the meaningful claim. --- src/youdotcom/models/searchrequestbody.py | 4 ++-- tests/test_live.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/youdotcom/models/searchrequestbody.py b/src/youdotcom/models/searchrequestbody.py index e880925..d7e3b1b 100644 --- a/src/youdotcom/models/searchrequestbody.py +++ b/src/youdotcom/models/searchrequestbody.py @@ -34,7 +34,7 @@ class SearchRequestBodyTypedDict(TypedDict): safesearch: NotRequired[SafeSearch] r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" knowledge: NotRequired[Knowledge] - r"""Requests knowledge results alongside web and news search.""" + r"""Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" livecrawl: NotRequired[LiveCrawl] r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" livecrawl_formats: NotRequired[List[LiveCrawlFormats]] @@ -91,7 +91,7 @@ class SearchRequestBody(BaseModel): r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" knowledge: Optional[Knowledge] = None - r"""Requests knowledge results alongside web and news search.""" + r"""Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" livecrawl: Optional[LiveCrawl] = None r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" diff --git a/tests/test_live.py b/tests/test_live.py index 1f79d35..1cd4506 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -440,7 +440,7 @@ def test_knowledge_result_shape(self, you_client): assert credit.name # description is required on type=answer results only if kr.type == "answer": - assert kr.description + assert kr.description is not None # as_of is optional; when present it is a bare YYYY-MM-DD date if kr.as_of is not None: datetime.strptime(kr.as_of, "%Y-%m-%d") From be9b0659c0702ea3023770f1279b8bcc59e954d6 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 15:32:46 -0700 Subject: [PATCH 06/24] docs: fix pre-existing Optional type cells and refresh the tests inventory (DX-835) Docs-only. Two independent staleness fixes found while auditing every doc surface in the repo. Type cells: 11 rows across 8 model pages declared a bare type where the annotation is Optional -- contentsrequest (urls, formats), sourcecontrol (include_domains, exclude_domains, boost_domains), source (snippets), searchrequest (livecrawl_formats), researchdetail and financeresearchdetail (ctx), researchrequest (output_schema), researchtaskstreameventdata (data). AGENTS.md names Optional[X] <-> X as a type-cell trap. All pre-date this branch. Every cell fits its existing padded column, so only those 11 lines change and table alignment is preserved. The Required cells were already right: is_required() is False for all 11. tests/README.md: listed 11 of the 20 test files and carried wrong counts (Contents 12 vs 13, Answer 23 vs 25). Now lists all 20, adds groups for extraction, knowledge, page_age, stream events and the cross-cutting suites, and labels counts as pytest --collect-only numbers. The groups sum to 425, exactly the CI-gate collection. Verified with a heading-aware audit comparing every field table in docs/models/ against typing.get_type_hints -- 151 rows, all four AGENTS.md traps (Optional<->X, datetime<->date, bool<->int, str<->int): 0 findings left. The 15 enum "## Values" pages were also diffed against their code members: clean, the one apparent hit being contentsformats' intentional "metadata _(deprecated)_" annotation. --- docs/models/contentsrequest.md | 4 +- docs/models/financeresearchdetail.md | 2 +- docs/models/researchdetail.md | 2 +- docs/models/researchrequest.md | 2 +- docs/models/researchtaskstreameventdata.md | 2 +- docs/models/searchrequest.md | 2 +- docs/models/source.md | 2 +- docs/models/sourcecontrol.md | 6 +-- tests/README.md | 57 ++++++++++++++++++++-- 9 files changed, 64 insertions(+), 15 deletions(-) diff --git a/docs/models/contentsrequest.md b/docs/models/contentsrequest.md index ba41c58..21f039f 100644 --- a/docs/models/contentsrequest.md +++ b/docs/models/contentsrequest.md @@ -5,7 +5,7 @@ | Field | Type | Required | Description | Example | | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `urls` | List[*str*] | :heavy_minus_sign: | Array of URLs to fetch the contents from. | | -| `formats` | List[[models.ContentsFormats](../models/contentsformats.md)] | :heavy_minus_sign: | Array of content formats to return. All included formats are returned in the response. The "metadata" format is deprecated and will be removed in a future major release. | [
"html",
"markdown"
] | +| `urls` | Optional[List[*str*]] | :heavy_minus_sign: | Array of URLs to fetch the contents from. | | +| `formats` | Optional[List[[models.ContentsFormats](../models/contentsformats.md)]] | :heavy_minus_sign: | Array of content formats to return. All included formats are returned in the response. The "metadata" format is deprecated and will be removed in a future major release. | [
"html",
"markdown"
] | | `crawl_timeout` | *Optional[int]* | :heavy_minus_sign: | Maximum time in seconds to wait for page content. Must be between 1 and 60 seconds. Default is 10 seconds. | 10 | | `max_age` | *OptionalNullable[int]* | :heavy_minus_sign: | Maximum allowed age of cached content in seconds. When set, cached content older than this threshold is ignored and the page is re-fetched. Must be 0 or greater. Default: null (no age limit, cached content is returned regardless of age). | 86400 | \ No newline at end of file diff --git a/docs/models/financeresearchdetail.md b/docs/models/financeresearchdetail.md index ca5a772..951cce1 100644 --- a/docs/models/financeresearchdetail.md +++ b/docs/models/financeresearchdetail.md @@ -9,4 +9,4 @@ | `loc` | List[[models.FinanceResearchLoc](../models/financeresearchloc.md)] | :heavy_check_mark: | The location of the error as a path of segments (strings for field names, integers for byte offsets). | [
"body",
"input"
] | | `msg` | *str* | :heavy_check_mark: | A human-readable description of the error. | Field required | | `input` | [models.FinanceResearchInputUnion](../models/financeresearchinputunion.md) | :heavy_check_mark: | The input value that caused the error. | | -| `ctx` | Dict[str, *Any*] | :heavy_minus_sign: | Additional context about the error. | | \ No newline at end of file +| `ctx` | Optional[Dict[str, *Any*]] | :heavy_minus_sign: | Additional context about the error. | | \ No newline at end of file diff --git a/docs/models/researchdetail.md b/docs/models/researchdetail.md index 8ee1a3b..ef69971 100644 --- a/docs/models/researchdetail.md +++ b/docs/models/researchdetail.md @@ -9,4 +9,4 @@ | `loc` | List[[models.ResearchLoc](../models/researchloc.md)] | :heavy_check_mark: | The location of the error as a path of segments (strings for field names, integers for byte offsets). | [
"body",
"input"
] | | `msg` | *str* | :heavy_check_mark: | A human-readable description of the error. | Field required | | `input` | [models.ResearchInputUnion](../models/researchinputunion.md) | :heavy_check_mark: | The input value that caused the error. | | -| `ctx` | Dict[str, *Any*] | :heavy_minus_sign: | Additional context about the error. | | \ No newline at end of file +| `ctx` | Optional[Dict[str, *Any*]] | :heavy_minus_sign: | Additional context about the error. | | \ No newline at end of file diff --git a/docs/models/researchrequest.md b/docs/models/researchrequest.md index cd83057..22aa3da 100644 --- a/docs/models/researchrequest.md +++ b/docs/models/researchrequest.md @@ -9,4 +9,4 @@ | `research_effort` | [Optional[models.ResearchEffort]](../models/researcheffort.md) | :heavy_minus_sign: | Controls how much time and effort the Research API spends on your question. Higher effort levels run more searches and dig deeper into sources, at the cost of a longer response time.

Available levels:
- `lite`: Returns answers quickly. Good for straightforward questions that just need a fast, reliable answer.
- `standard`: The default. Balances speed and depth, a good fit for most questions.
- `deep`: Spends more time researching and cross-referencing sources. Use this when accuracy and thoroughness matter more than speed.
- `exhaustive`: The most thorough option. Explores the topic as fully as possible, best suited for complex research tasks where you want the highest quality result.
- `frontier`: The highest-quality, longest-running tier (up to 4 hours). Requires `background=true`; returns 422 otherwise. | | `background` | *Optional[bool]* | :heavy_minus_sign: | When true, queue a research task and return a task handle immediately instead of waiting for the result inline. Defaults to synchronous. When enabled, the response is a TaskResponse object with a task_id and stream_url for polling progress via GET /v1/research/{task_id} or streaming via GET /v1/research/{task_id}/stream. | | `source_control` | [Optional[models.SourceControl]](../models/sourcecontrol.md) | :heavy_minus_sign: | Beta. Controls which web sources the research agent searches and visits. Use this to allow specific domains, block specific domains, boost specific domains, filter by recency, or focus web results by country.

`include_domains` and `exclude_domains` cannot be used together. Each domain list is capped at 500 entries. `exclude_domains` also blocks the research agent from visiting pages on those domains during browsing. `boost_domains` gives matching domains a relative ranking boost without filtering out other domains. It can be combined with `exclude_domains` but cannot be combined with `include_domains`. | -| `output_schema` | Dict[str, *Any*] | :heavy_minus_sign: | Beta. Requests structured JSON output in output.content using a supported JSON Schema subset. Supported only with research_effort values standard, deep, and exhaustive. Sending output_schema with research_effort: "lite" returns 422.

Schema rules: Root must be a JSON object. Top-level anyOf is not allowed. Every object must define properties and set additionalProperties: false. Every property must be listed in required. Recursive schemas are not supported.

Limits: Max nesting depth 5, max total properties 100, max total enum values 500, max total schema string budget 25,000. | \ No newline at end of file +| `output_schema` | Optional[Dict[str, *Any*]] | :heavy_minus_sign: | Beta. Requests structured JSON output in output.content using a supported JSON Schema subset. Supported only with research_effort values standard, deep, and exhaustive. Sending output_schema with research_effort: "lite" returns 422.

Schema rules: Root must be a JSON object. Top-level anyOf is not allowed. Every object must define properties and set additionalProperties: false. Every property must be listed in required. Recursive schemas are not supported.

Limits: Max nesting depth 5, max total properties 100, max total enum values 500, max total schema string budget 25,000. | \ No newline at end of file diff --git a/docs/models/researchtaskstreameventdata.md b/docs/models/researchtaskstreameventdata.md index 56634db..ca0abd9 100644 --- a/docs/models/researchtaskstreameventdata.md +++ b/docs/models/researchtaskstreameventdata.md @@ -10,6 +10,6 @@ The event payload. Structure varies by event type. Common fields include type, t | `type` | *Optional[str]* | :heavy_minus_sign: | The event type identifier. | | `task_id` | *Optional[str]* | :heavy_minus_sign: | The task UUID. | | `status` | *Optional[str]* | :heavy_minus_sign: | Current task status when the event was emitted. | -| `data` | Dict[str, *Any*] | :heavy_minus_sign: | Event-specific payload data. | +| `data` | Optional[Dict[str, *Any*]] | :heavy_minus_sign: | Event-specific payload data. | | `error` | *OptionalNullable[str]* | :heavy_minus_sign: | Error message if the event represents an error. | | `sequence` | *Optional[int]* | :heavy_minus_sign: | Event sequence number. | \ No newline at end of file diff --git a/docs/models/searchrequest.md b/docs/models/searchrequest.md index 3c07e4c..96f3102 100644 --- a/docs/models/searchrequest.md +++ b/docs/models/searchrequest.md @@ -13,7 +13,7 @@ | `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | | `livecrawl` | [Optional[models.LiveCrawl]](../models/livecrawl.md) | :heavy_minus_sign: | Indicates which section(s) of search results to livecrawl and return full page content. | | -| `livecrawl_formats` | List[[models.LiveCrawlFormats](../models/livecrawlformats.md)] | :heavy_minus_sign: | N/A | | +| `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../models/livecrawlformats.md)]] | :heavy_minus_sign: | N/A | | | `include_domains` | *Optional[str]* | :heavy_minus_sign: | A list of domains to restrict search results to. Only results from these domains will be returned. For large domain lists (up to 500), use POST with a JSON array instead. This is a strict allowlist — cannot be combined with `exclude_domains` (returns `422`).

**Important:** Use a single comma-separated value (e.g. `include_domains=nytimes.com,bbc.com`). Repeated parameters (`include_domains=a.com&include_domains=b.com`) are not supported. | nytimes.com,bbc.com | | `exclude_domains` | *Optional[str]* | :heavy_minus_sign: | A list of domains to exclude from search results. Results from these domains will be filtered out. For large domain lists (up to 500), use POST with a JSON array instead. Cannot be combined with `include_domains` (returns `422`).

**Important:** You must use a single comma-separated value (e.g. `exclude_domains=spam-site.com,other-site.com`). Repeated parameters are not supported. | spam-site.com,other-site.com | | `boost_domains` | *Optional[str]* | :heavy_minus_sign: | A list of domains to boost in search ranking. Matching results from these domains receive a relative ranking boost, but results are not limited to these domains. Supports up to 500 domains. Can be combined with `exclude_domains`, but cannot be combined with `include_domains` (returns `422`).

**Important:** You must use a single comma-separated value (e.g. `boost_domains=nytimes.com,wired.com`). Repeated parameters are not supported. | nytimes.com,wired.com | diff --git a/docs/models/source.md b/docs/models/source.md index 174794d..5e51467 100644 --- a/docs/models/source.md +++ b/docs/models/source.md @@ -7,4 +7,4 @@ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | | `title` | *Optional[str]* | :heavy_minus_sign: | The title of the source webpage. | -| `snippets` | List[*str*] | :heavy_minus_sign: | Relevant excerpts from the source page that were used in generating the answer. | \ No newline at end of file +| `snippets` | Optional[List[*str*]] | :heavy_minus_sign: | Relevant excerpts from the source page that were used in generating the answer. | \ No newline at end of file diff --git a/docs/models/sourcecontrol.md b/docs/models/sourcecontrol.md index cc42a85..c1fdc62 100644 --- a/docs/models/sourcecontrol.md +++ b/docs/models/sourcecontrol.md @@ -9,8 +9,8 @@ Beta. Controls which web sources the research agent searches and visits. Use thi | Field | Type | Required | Description | | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `include_domains` | List[*str*] | :heavy_minus_sign: | Only return results from these domains. Max 500 domains. Cannot be used with exclude_domains or boost_domains. | -| `exclude_domains` | List[*str*] | :heavy_minus_sign: | Never return results from these domains. Max 500 domains. Also blocks the research agent from visiting pages on those domains during browsing. | -| `boost_domains` | List[*str*] | :heavy_minus_sign: | Boost results from these domains without excluding other domains. Max 500 domains. Cannot be used with include_domains. | +| `include_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Only return results from these domains. Max 500 domains. Cannot be used with exclude_domains or boost_domains. | +| `exclude_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Never return results from these domains. Max 500 domains. Also blocks the research agent from visiting pages on those domains during browsing. | +| `boost_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Boost results from these domains without excluding other domains. Max 500 domains. Cannot be used with include_domains. | | `freshness` | *Optional[str]* | :heavy_minus_sign: | Filter results by recency. Accepts `day`, `week`, `month`, `year`, or a custom date range in `YYYY-MM-DDtoYYYY-MM-DD` format. | | `country` | *Optional[str]* | :heavy_minus_sign: | ISO 3166-1 alpha-2 country code, such as US, GB, or DE, to geographically focus web results. | \ No newline at end of file diff --git a/tests/README.md b/tests/README.md index f4e6146..77c321e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -53,13 +53,22 @@ pytest tests/ -v - `test_client.py` - Helper utilities for creating test HTTP clients - `test_search.py` - Tests for the Search API (`/v1/search`) +- `test_extraction.py` - Tests for the `extraction` parameter on `you.search` (model contract, strict validation, wire contract, conflicts, plus-value rule, async) +- `test_knowledge.py` - Tests for the `knowledge` parameter and the knowledge result models on `you.search` +- `test_page_age.py` - Tolerance of non-ISO `page_age` values on search and news results - `test_contents.py` - Tests for the Contents API (`/v1/contents`) - `test_answer.py` - Tests for the Answer API (`/v1/answer`) - `test_direct_methods.py` - Tests for direct methods on `You` (search, contents) - `test_shims.py` - Tests for backward-compat sub-SDK shims with DeprecationWarning +- `test_param_normalization.py` - Tests for plain-string parameter normalization (case folding, `language`, deprecated shims) - `test_research.py` - Tests for the Research API (`/v1/research`) including background mode, output_schema, and source_control - `test_research_helpers.py` - Tests for the hand-maintained `research_helpers` module (background submission, polling, streaming, research_and_wait) +- `test_researchtaskstreamevent.py` - Tests for `ResearchTaskStreamEvent` model contracts and the real SSE decode path - `test_security_env.py` - Tests for environment variable precedence (`YDC_API_KEY` / `YOU_API_KEY_AUTH`) +- `test_attribution.py` - Tests for the `X-Client-Info` attribution header (grammar, edge cases, construction-time validation, wire round-trip, version resolution) +- `test_redaction.py` - Tests for debug-log header redaction +- `test_client_lifecycle.py` - Tests for client teardown in `You.__exit__` / `You.__aexit__` +- `test_root_init.py` - Tests for the `youdotcom` package root module - `test_performance.py` - Performance/instrumentation tests measuring SDK overhead - `test_live.py` - Live API tests that run against the real You.com API (requires API key) @@ -67,6 +76,10 @@ pytest tests/ -v Tests are organized into logical classes using pytest: +Counts below are collected tests (`pytest --collect-only`), so a parametrized case +counts once per parameter set. The groups sum to the 425 tests in the CI gate; +`test_performance.py` and `test_live.py` are excluded from that gate. + **Search API** (10 tests): - Basic search functionality - Search with filters (freshness, country, safesearch) @@ -74,19 +87,35 @@ Tests are organized into logical classes using pytest: - News livecrawl with contents - Error handling (unauthorized, forbidden, unprocessable, internal server error) -**Contents API** (12 tests): +**Extraction** (38 tests): +- Model contract and strict validation +- Wire contract and mutual exclusion with the deprecated `livecrawl` +- Plus-value rule and async parity + +**Knowledge** (21 tests): +- `Knowledge` enum and plain-string normalization +- Request wire contract on `search` / `search_async` +- Response parsing into `KnowledgeResult` / `KnowledgeAttribution` +- End-to-end round-trip and sub-SDK shim parity + +**Page age tolerance** (15 tests): +- ISO values still parse to `datetime` +- Non-ISO values returned verbatim instead of failing the whole response +- Wrong JSON types still raise + +**Contents API** (13 tests): - HTML and Markdown format generation - Single and multiple URL processing - Optional format parameter - Error handling (unauthorized, forbidden, empty URLs) -**Answer API** (23 tests): +**Answer API** (25 tests): - Basic answer functionality - Answer with freshness, country, boost domains - Async answer - Error handling (unauthorized, forbidden, payment required, unprocessable, internal server error) -**Research API**: +**Research API** (34 tests): - Basic research functionality (standard, deep, exhaustive effort) - Background mode (task submission, get_research_task, status polling) - Output schema (structured JSON output, content_type object) @@ -94,13 +123,33 @@ Tests are organized into logical classes using pytest: - Error handling (unauthorized, forbidden, unprocessable entity, 422 combos) - Stream research task (SSE success path + 404/401/403 error paths) -**Research Helpers**: +**Research Helpers** (57 tests): - research_background / research_background_async (TaskResponse return) - poll_research_task / poll_research_task_async (terminal status) - research_and_wait / research_and_wait_async (submit + wait) - stream_research / stream_research_async (tolerant SSE) - RawStreamEvent decoder (_decode_raw_event) +**Research Task Stream Events** (26 tests): +- Known and unknown event names +- Round-trip and declared-type contracts +- End-to-end pin through the real SSE decode path + +**Cross-cutting** (186 tests): +- `X-Client-Info` attribution header: grammar, edge cases, construction-time + validation, wire round-trip, version resolution (91) +- Plain-string parameter normalization: case folding, `language` three-way + contract, deprecated shims (35) +- Environment variable precedence `YDC_API_KEY` / `YOU_API_KEY_AUTH` (17) +- Debug-log header redaction (14) +- Client teardown in `You.__exit__` / `You.__aexit__` (10) +- Direct methods on `You` (8) and backward-compat sub-SDK shims (6) +- `youdotcom` package root module (5) + +**Outside the CI gate**: +- `test_performance.py` (33 tests) - SDK overhead instrumentation +- `test_live.py` (46 tests) - runs against the real API, requires an API key + ### Running Live Tests The `test_live.py` file contains tests that run against the real You.com API. All tests require an API key and are skipped unless `YDC_API_KEY` or `YOU_API_KEY_AUTH` is set: From e405cca7acf37903cc0ac673c98637fae741b3b8 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 15:45:22 -0700 Subject: [PATCH 07/24] docs: make the as_of parsing note self-contained and add timeout_ms to the retries example (DX-835) Two docs footguns, one from review and one found sweeping every snippet. docs/models/knowledgeresult.md: the `as_of` note told readers to call `datetime.strptime(card.as_of, "%Y-%m-%d")` without an import, so following it raised NameError. Replaced with a fenced block that carries `from datetime import datetime`, matching the pattern docs/models/webresult.md already uses for its own `page_age` datetime note. The block also guards the optional section (`res.results.knowledge or []`) and uses `is not None` rather than truthiness, consistent with the model's Optional[str] annotation. README.md: the Retries example builds its own client but passed no `timeout_ms`, so copying it inherits httpx's 5s default and raises ReadTimeout -- the exact failure the adjacent Timeouts section warns about. AGENTS.md requires an explicit `timeout_ms` on any snippet that triggers a round-trip. The other README snippets that call `you.search` / `you.contents` without a `timeout_ms` are deliberately untouched: they reuse the client constructed in Quick Start, which already passes `timeout_ms=60_000`, so the rule is satisfied once upstream rather than per snippet. --- README.md | 4 ++-- docs/models/knowledgeresult.md | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0ed574c..3da99bc 100644 --- a/README.md +++ b/README.md @@ -378,8 +378,8 @@ retries = RetryConfig( retry_connection_errors=True, ) -with You(api_key_auth=key, retry_config=retries) as you: # whole client - res = you.search(query="...", retries=retries) # or one call +with You(api_key_auth=key, retry_config=retries, timeout_ms=60_000) as you: # whole client + res = you.search(query="...", retries=retries) # or one call ``` Retries apply to `429`, `500`, `502`, `503`, and `504`. diff --git a/docs/models/knowledgeresult.md b/docs/models/knowledgeresult.md index 0d0dabe..8f07f62 100644 --- a/docs/models/knowledgeresult.md +++ b/docs/models/knowledgeresult.md @@ -62,6 +62,13 @@ it. ### `as_of` is a string, not a date -`as_of` stays a `str` in `YYYY-MM-DD` form. Parse it with -`datetime.strptime(card.as_of, "%Y-%m-%d")` when you need a date object, and -expect `None` when the provider reports no date. +`as_of` stays a `str` in `YYYY-MM-DD` form, and is `None` when the provider +reports no date. Parse it when you need a date object: + +```python +from datetime import datetime + +for card in res.results.knowledge or []: + if card.as_of is not None: + print(datetime.strptime(card.as_of, "%Y-%m-%d").date()) +``` From 8120ae81796a86532498dc3641cdf878ff61dc2f Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 18:32:22 -0700 Subject: [PATCH 08/24] test: stop naming an unpublished knowledge value in the enum tests Two spots in tests/test_knowledge.py referenced a `knowledge` value that the published spec does not define -- one in a docstring that also described it as not public yet, which discloses an unreleased parameter value in a public repo, and one as the input to the invalid-value case. Both now state the contract without naming anything unpublished. The docstring explains that `core` is the only value the published spec defines and why the enum is pinned to exactly that, and the invalid-value case uses an obviously non-value string instead. Coverage is unchanged: the enum is still pinned to ["core"], and an unrecognized value still raises ValidationError locally before any request is sent. 21 knowledge tests and the 425-test offline gate pass, mypy clean, pylint 10.00/10 on the CI errors-only gate, drift --strict no_drift. --- tests/test_knowledge.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py index d5645ea..5498361 100644 --- a/tests/test_knowledge.py +++ b/tests/test_knowledge.py @@ -89,8 +89,9 @@ def test_core_member(self): assert Knowledge.CORE.value == "core" def test_core_is_the_only_member(self): - """``knowledge=detailed`` is not public yet — shipping it here would - let callers send a value the API rejects with ``422``.""" + """``core`` is the only value the published spec defines. Adding a member + the API does not accept would let callers send a value that fails with + ``422``, so this pins the enum to exactly what is public.""" assert [m.value for m in Knowledge] == ["core"] @@ -128,7 +129,7 @@ def test_invalid_value_raises_locally(self): request is sent -- the same local-mirrors-server pattern as ``extraction``.""" with pytest.raises(ValidationError): - _search_body(knowledge="detailed") + _search_body(knowledge="not-a-real-value") # --------------------------------------------------------------------------- From 5a9a4701b6db80b051d7a65bb9afe1f5b86a921d Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 19:32:43 -0700 Subject: [PATCH 09/24] test: share one async capture helper instead of copy-pasting the harness (DX-835) The two async knowledge tests each re-declared the mock-transport handler and the AsyncClient/You scaffolding already living in `_capture`, differing only in the response body. Extracted `_acapture` as the async twin. Worth doing for the lifecycle rather than the line count (net +1): the suite treats a leaked transport as a failure via ResourceWarning-as-error, and the copy-pasted form put an `await client.aclose()` in every caller's hands. One helper now owns it, so a future async test cannot forget it. Coverage unchanged: 21 knowledge tests and the 425-test offline gate pass with no ResourceWarning. --- tests/test_knowledge.py | 65 +++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/tests/test_knowledge.py b/tests/test_knowledge.py index 5498361..05f968e 100644 --- a/tests/test_knowledge.py +++ b/tests/test_knowledge.py @@ -16,7 +16,7 @@ """ import json -from contextlib import contextmanager +from contextlib import asynccontextmanager, contextmanager import httpx import pytest @@ -50,6 +50,33 @@ def handler(request): _EMPTY = {"results": {"web": []}, "metadata": {"query": "q"}} +@asynccontextmanager +async def _acapture(response_body: dict): + """Async twin of ``_capture``: yield ``(You, captured)`` over a mock transport. + + Owns the ``AsyncClient`` lifetime so a caller cannot leak the transport, + which the suite treats as a failure (ResourceWarning-as-error). + """ + captured: dict = {} + + def handler(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=json.dumps(response_body), + ) + + client = httpx.AsyncClient(transport=httpx.MockTransport(handler)) + try: + async with You( + api_key_auth="k", server_url="http://mock.local", async_client=client + ) as you: + yield you, captured + finally: + await client.aclose() + + def _search_body(**kwargs) -> dict: """Run one synchronous search; return the JSON body that went over the wire.""" with _capture(_EMPTY) as (you, captured): @@ -220,21 +247,8 @@ def test_search_returns_parsed_knowledge(self): class TestKnowledgeParity: @pytest.mark.asyncio async def test_search_async_sends_knowledge(self): - captured: dict = {} - - def handler(request): - captured["body"] = json.loads(request.content) - return httpx.Response( - 200, - headers={"content-type": "application/json"}, - content=json.dumps({"results": {"knowledge": [_ANSWER]}}), - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as ac: - async with You( - api_key_auth="k", server_url="http://mock.local", async_client=ac - ) as you: - resp = await you.search_async(query="q", knowledge="core") + async with _acapture({"results": {"knowledge": [_ANSWER]}}) as (you, captured): + resp = await you.search_async(query="q", knowledge="core") assert captured["body"]["knowledge"] == "core" assert resp.results.knowledge[0].type == "answer" @@ -247,21 +261,8 @@ def test_deprecated_unified_passes_knowledge(self): @pytest.mark.asyncio async def test_deprecated_unified_async_passes_knowledge(self): - captured: dict = {} - - def handler(request): - captured["body"] = json.loads(request.content) - return httpx.Response( - 200, - headers={"content-type": "application/json"}, - content=json.dumps(_EMPTY), - ) - - async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as ac: - async with You( - api_key_auth="k", server_url="http://mock.local", async_client=ac - ) as you: - with pytest.warns(DeprecationWarning): - await you.search.unified_async(query="q", knowledge="core") + async with _acapture(_EMPTY) as (you, captured): + with pytest.warns(DeprecationWarning): + await you.search.unified_async(query="q", knowledge="core") assert captured["body"]["knowledge"] == "core" From bc6ff19c8b96d0f9eade462da3654e05f5facdfc Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 19:50:41 -0700 Subject: [PATCH 10/24] fix: make the response drift walk a recursion stack, not a global cache (DX-835) _compare_response_fields() used `visited` as an "already compared this model" cache, returning early whenever the same pydantic class reappeared. One model can legitimately sit at several response paths backed by different spec schemas, so every branch after the first was skipped and real drift went unreported. Reproduced before fixing: a root model with two fields of the same type whose spec schemas differ by one field reported drift on the first path only and silently missed the second. `visited` is now discarded on exit via try/finally, so it breaks cycles without suppressing sibling branches. Checked against self-referential and mutual A->B->A schemas: both still terminate, and drift inside the cycle is still reported. Fixing the cache unmasked one genuine asymmetry, now suppressed explicitly rather than left as a standing warning: web-search results.news.contents: SDK has `highlights`, spec does not results.web[].contents resolves to WebContentsPost, which defines `highlights`, while results.news[].contents resolves to the narrower Contents schema (html, markdown). The SDK shares one Contents model for both. Confirmed against prod that highlights never arrives on the news path: with extraction_mode=highlights news items carry no contents at all, and with the deprecated livecrawl=all they carry html only. Narrowing the model would be a breaking change for no behavioral gain, so KNOWN_SHARED_MODEL_EXTRAS records the gap. It mirrors KNOWN_RESPONSE_GAPS and is self-invalidating the same way, reporting the entry as stale if the spec ever defines the field at that path. drift --verbose and --strict both clean. 425 offline tests, all 46 live tests against prod, mypy clean over 85 files, pylint 10.00/10 on the CI gate. --- scripts/check_drift.py | 136 +++++++++++++++++++++++++---------------- 1 file changed, 85 insertions(+), 51 deletions(-) diff --git a/scripts/check_drift.py b/scripts/check_drift.py index 0a3bd3d..d93e472 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -56,6 +56,22 @@ ("finance-research", "output.sources"): {"snippets"}, } +# The mirror image: fields an SDK model declares that the spec schema at *this* +# path does not. The SDK shares one model across paths whose spec schemas differ +# in width, so the shared model is wider than some of them. Each entry is a field +# that is Optional and never populated at that path, so narrowing the model would +# be a breaking change with no behavioral gain. An entry that goes stale — the +# spec catches up — is reported rather than silently kept. +KNOWN_SHARED_MODEL_EXTRAS = { + # `results.web[].contents` resolves to WebContentsPost, which defines + # `highlights`; `results.news[].contents` resolves to the narrower Contents + # schema (`html`, `markdown` only). The SDK uses one Contents model for both. + # Verified against prod: with `extraction_mode="highlights"` news items carry + # no `contents` at all, and with the deprecated `livecrawl="all"` they carry + # `html` only — `highlights` never arrives on the news path. + ("web-search", "results.news.contents"): {"highlights"}, +} + # Map (method, path) -> SDK method name. # {task_id} and {task_id}/stream are handled by helpers, not direct methods. EXPECTED_ENDPOINTS = { @@ -449,60 +465,78 @@ def _compare_response_fields( """ if model in visited: return + # ``visited`` is a recursion stack, not an "already compared" cache. The same + # model can legitimately appear at several response paths backed by different + # schemas, so it has to be compared at each one; discarding on exit keeps the + # cycle breaker (a model already on the stack) without silently suppressing + # sibling branches and missing real drift. visited.add(model) + try: + schema = _resolve_schema(schema, spec) + props = schema.get("properties") + if not props: + # Nothing to compare against — a `oneOf` union or an unresolvable ref. + # Bail rather than reporting every SDK field as absent from the spec. + return + model_fields = set(model.model_fields.keys()) + + missing_in_sdk = set(props) - model_fields + missing_in_spec = model_fields - set(props) + + known = KNOWN_RESPONSE_GAPS.get((spec_name, field_path), set()) + # Stale means the SDK model now defines the field, so the suppression no + # longer does anything. Compare against the model, not against what's + # missing: a field the spec dropped is neither missing nor defined, and + # must not be reported as stale. + stale = known & model_fields + if stale: + warnings.append( + f"[response] {path}: KNOWN_RESPONSE_GAPS entry {stale} is stale — " + f"the SDK model now defines it, so remove the entry" + ) + missing_in_sdk -= known - schema = _resolve_schema(schema, spec) - props = schema.get("properties") - if not props: - # Nothing to compare against — a `oneOf` union or an unresolvable ref. - # Bail rather than reporting every SDK field as absent from the spec. - return - model_fields = set(model.model_fields.keys()) - - missing_in_sdk = set(props) - model_fields - missing_in_spec = model_fields - set(props) - - known = KNOWN_RESPONSE_GAPS.get((spec_name, field_path), set()) - # Stale means the SDK model now defines the field, so the suppression no - # longer does anything. Compare against the model, not against what's - # missing: a field the spec dropped is neither missing nor defined, and - # must not be reported as stale. - stale = known & model_fields - if stale: - warnings.append( - f"[response] {path}: KNOWN_RESPONSE_GAPS entry {stale} is stale — " - f"the SDK model now defines it, so remove the entry" - ) - missing_in_sdk -= known - - if missing_in_sdk: - warnings.append( - f"[response] {path}: spec has fields {missing_in_sdk} " - f"which SDK model doesn't have" - ) - if missing_in_spec: - warnings.append( - f"[response] {path}: SDK model has fields {missing_in_spec} " - f"which spec doesn't define" - ) - - for prop_name, prop_schema in props.items(): - if prop_name not in model_fields: - continue - child_model = _nested_model(model.model_fields[prop_name].annotation) - if child_model is None: - continue - if "properties" in _resolve_schema(prop_schema, spec): - _compare_response_fields( - prop_schema, - child_model, - f"{path}.{prop_name}", - spec, - warnings, - visited, - spec_name, - f"{field_path}.{prop_name}" if field_path else prop_name, + known_extra = KNOWN_SHARED_MODEL_EXTRAS.get((spec_name, field_path), set()) + # Stale here is the mirror image: the spec caught up and now defines the + # field at this path, so the suppression no longer does anything. + stale_extra = known_extra & set(props) + if stale_extra: + warnings.append( + f"[response] {path}: KNOWN_SHARED_MODEL_EXTRAS entry {stale_extra} " + f"is stale — the spec now defines it at this path, so remove the entry" ) + missing_in_spec -= known_extra + + if missing_in_sdk: + warnings.append( + f"[response] {path}: spec has fields {missing_in_sdk} " + f"which SDK model doesn't have" + ) + if missing_in_spec: + warnings.append( + f"[response] {path}: SDK model has fields {missing_in_spec} " + f"which spec doesn't define" + ) + + for prop_name, prop_schema in props.items(): + if prop_name not in model_fields: + continue + child_model = _nested_model(model.model_fields[prop_name].annotation) + if child_model is None: + continue + if "properties" in _resolve_schema(prop_schema, spec): + _compare_response_fields( + prop_schema, + child_model, + f"{path}.{prop_name}", + spec, + warnings, + visited, + spec_name, + f"{field_path}.{prop_name}" if field_path else prop_name, + ) + finally: + visited.discard(model) def check_response_schemas(specs: dict[str, dict[str, Any]]) -> list[str]: From 8b496a0aec700cce9fd642978422af50f5ab3f0a Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 20:17:25 -0700 Subject: [PATCH 11/24] docs: guard optional res.results in knowledge snippets; refresh two stale descriptions (DX-835) Five review findings: four fixed, one rejected. `SearchResponse.results` is `Optional[Results]` and not required, so `for card in res.results.knowledge or []:` still raises AttributeError when a response omits `results` -- the `or []` only guards the inner key. Guarded all five snippets (README, USAGE, and three in docs/models/knowledgeresult.md) with `if res.results:`, matching examples/api-example-calls.py, which already did it correctly. `SearchResponseTypedDict` / `SearchResponse` described the payload as "unified search results from web and news sources"; `Results` now carries a third section. Updated both docstrings and the mirrored docs/models/searchresponse.md. That text is SDK-authored -- `SearchPostResponse.description` is null in the published spec -- so there is no spec wording to stay verbatim against. The endpoint summaries in docs/sdks/* and sdk.py are left alone deliberately: they quote the spec's operation.summary verbatim, the spec has not been updated, and knowledge is opt-in, so describing the endpoint's default output as web and news is still accurate. The knowledge perf case measures the request side only under the default mock target, because tests/mockserver returns one fixed payload regardless of the request body and includes no knowledge section. Documented that in the docstring rather than changing the Go mock: the mock ignores the request entirely, so emitting knowledge conditionally would mean parsing the body, and the pre-existing extraction cases have the same property. Rejected: the claim that tests/README.md undercounts test_performance.py (33 vs 34). pytest collects 33; the 34th `def test_` line is `test_target`, a module-level @pytest.fixture, not a test. The doc's counts are labelled as `pytest --collect-only` numbers and match. --- README.md | 9 +++++---- USAGE.md | 7 ++++--- docs/models/knowledgeresult.md | 21 ++++++++++++--------- docs/models/searchresponse.md | 2 +- src/youdotcom/models/searchresponse.py | 4 ++-- tests/test_performance.py | 9 ++++++++- 6 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 3da99bc..ab9c550 100644 --- a/README.md +++ b/README.md @@ -160,10 +160,11 @@ with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: query="what is the capital of France", knowledge="core", ) - for card in res.results.knowledge or []: - print(card.title) - print(card.description) - print([credit.name for credit in card.attribution]) + if res.results: + for card in res.results.knowledge or []: + print(card.title) + print(card.description) + print([credit.name for credit in card.attribution]) ``` `"core"` is the only value the API accepts; anything else raises diff --git a/USAGE.md b/USAGE.md index 2adde62..8ae0085 100644 --- a/USAGE.md +++ b/USAGE.md @@ -117,9 +117,10 @@ with You( knowledge="core", ) - for card in res.results.knowledge or []: - print(card.title, card.description) - print([credit.name for credit in card.attribution]) + if res.results: + for card in res.results.knowledge or []: + print(card.title, card.description) + print([credit.name for credit in card.attribution]) ``` `knowledge="core"` requests knowledge results — cards backed by licensed data diff --git a/docs/models/knowledgeresult.md b/docs/models/knowledgeresult.md index 8f07f62..7d298ca 100644 --- a/docs/models/knowledgeresult.md +++ b/docs/models/knowledgeresult.md @@ -13,10 +13,11 @@ from youdotcom import You with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: res = you.search(query="what is the capital of France", knowledge="core") - for card in res.results.knowledge or []: - print(card.type, card.title) - print(card.description) - print([credit.name for credit in card.attribution]) + if res.results: + for card in res.results.knowledge or []: + print(card.type, card.title) + print(card.description) + print([credit.name for credit in card.attribution]) ``` Knowledge results arrive in the response rather than being constructed by the @@ -43,8 +44,9 @@ empty array, so `response.results.knowledge` is `None` — check for `None` before iterating. ```python -for card in res.results.knowledge or []: - print(card.title) +if res.results: + for card in res.results.knowledge or []: + print(card.title) ``` ### `count` does not cap knowledge @@ -68,7 +70,8 @@ reports no date. Parse it when you need a date object: ```python from datetime import datetime -for card in res.results.knowledge or []: - if card.as_of is not None: - print(datetime.strptime(card.as_of, "%Y-%m-%d").date()) +if res.results: + for card in res.results.knowledge or []: + if card.as_of is not None: + print(datetime.strptime(card.as_of, "%Y-%m-%d").date()) ``` diff --git a/docs/models/searchresponse.md b/docs/models/searchresponse.md index 0a0dcb7..3e69866 100644 --- a/docs/models/searchresponse.md +++ b/docs/models/searchresponse.md @@ -1,6 +1,6 @@ # SearchResponse -A JSON object containing unified search results from web and news sources +A JSON object containing unified search results from web, news, and knowledge sources ## Fields diff --git a/src/youdotcom/models/searchresponse.py b/src/youdotcom/models/searchresponse.py index 72f120c..5dc38d0 100644 --- a/src/youdotcom/models/searchresponse.py +++ b/src/youdotcom/models/searchresponse.py @@ -44,14 +44,14 @@ def serialize_model(self, handler): class SearchResponseTypedDict(TypedDict): - r"""A JSON object containing unified search results from web and news sources""" + r"""A JSON object containing unified search results from web, news, and knowledge sources""" results: NotRequired[ResultsTypedDict] metadata: NotRequired[SearchMetadataTypedDict] class SearchResponse(BaseModel): - r"""A JSON object containing unified search results from web and news sources""" + r"""A JSON object containing unified search results from web, news, and knowledge sources""" results: Optional[Results] = None diff --git a/tests/test_performance.py b/tests/test_performance.py index eca2a14..4baa604 100644 --- a/tests/test_performance.py +++ b/tests/test_performance.py @@ -575,7 +575,14 @@ def call(): # ---------------------------------------------------------------- def test_search_with_knowledge_core(self, server_url, api_key, iterations, show_detailed): - """Search with knowledge="core" (adds the results.knowledge section).""" + """Search with knowledge="core" (adds the results.knowledge section). + + Under the default ``PERF_TEST_TARGET=mock`` the server returns one fixed + payload regardless of the request body, with no ``knowledge`` section, so + this measures the request side only. Point ``PERF_TEST_TARGET`` at a real + server to include knowledge response parsing and payload size. The + extraction cases above have the same caveat. + """ client = create_timing_client("post_/v1/search") with You(server_url=server_url, client=client, api_key_auth=api_key, timeout_ms=90_000) as you: From 5be45e52d0b2f987d9198c103c5eb49c4d40adfb Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 21:28:50 -0700 Subject: [PATCH 12/24] fix: stop dropping documented response fields on answer and finance research (DX-835) Two response models were narrower than the spec they parse against, so documented fields were discarded at parse time. AnswerSearchResult lacked `description` and `thumbnail_url`. The answer spec defines both on results.web[] items and prod returns them: a live call came back with description on 20/20 web results and thumbnail_url on 16/20, all silently dropped. WebResult on the search endpoint already declared both, so the answer model was simply the narrower of two siblings describing the same shape. FinanceResearchSource lacked `snippets`. The finance-research spec defines it on output.sources[] and the sibling Source model on the Research API already declared it, with an identical description. Prod was not returning it -- two live calls at `deep` and `exhaustive`, 10 sources between them, all carrying only title and url -- so nothing was being lost yet. Added anyway: it was held back by a KNOWN_RESPONSE_GAPS suppression whose stale check can only see the SDK catching up, never the API starting to honour its own spec, so that gap would have stayed silent from the API side indefinitely. Both suppressions are removed and KNOWN_RESPONSE_GAPS is now empty. drift --verbose reports no drift with an empty table, which is the actual proof the gaps closed rather than got hidden; the stale mechanism was negative-tested separately so it is still live for the next one. The Go mock for finance_research now emits `snippets`, and its comment no longer claims the model defines only url and title. tests/test_research.py asserted that old behaviour and now asserts snippets parse. tests/test_answer.py feeds both new fields through _ANSWER_BODY and pins them present on the first web result and None on the second, covering optionality as well. Folds into the unreleased 3.5.0 rather than bumping again; CHANGELOG records both under Fixed. --- CHANGELOG.md | 28 ++++++++++++++----- docs/models/answersearchresult.md | 2 ++ docs/models/financeresearchsource.md | 9 +++--- scripts/check_drift.py | 20 +++++++------ src/youdotcom/models/answersearchresult.py | 6 ++++ src/youdotcom/models/finance_researchop.py | 7 ++++- .../handler/pathpostv1financeresearch.go | 11 ++++---- tests/test_answer.py | 9 +++++- tests/test_research.py | 4 ++- 9 files changed, 68 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08c43d3..26a9b7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [3.5.0] - 2026-09-21 Minor release. Adds support for the new `knowledge` parameter on -`POST /v1/search` and the knowledge result models that come back with it. -Purely additive — no breaking changes. +`POST /v1/search` and the knowledge result models that come back with it, and +closes two documented response fields the SDK had been dropping. Purely +additive — no breaking changes. ### Added @@ -34,15 +35,28 @@ Purely additive — no breaking changes. rather than an empty list and iterating needs an `or []` guard. `count` caps the web and news sections, not knowledge. +### Fixed + +- **`AnswerSearchResult` was dropping `description` and `thumbnail_url`** — the + answer spec defines both on `results.web[]` and the API returns them on every + result, but the model did not declare them, so they were discarded at parse + time. Both are now optional `str` fields. `WebResult` on the search endpoint + already had them; the answer model was simply the narrower of the two. +- **`FinanceResearchSource` was missing `snippets`** — the finance-research spec + defines it on `output.sources[]`, and the sibling `Source` model on the + Research API already declared it. Production was not returning the field at + the time of this release, so nothing was being lost yet, but the model now + matches the published contract instead of relying on a drift-checker + suppression that could not have noticed the API starting to send it. + ### Changed - **`scripts/check_drift.py` recurses nested response schemas** — the response check previously compared top-level fields only, so drift inside a nested - object went undetected. Two pre-existing gaps that recursion surfaced - (`AnswerSearchResult.description` / `.thumbnail_url` and - `FinanceResearchSource.snippets`) are listed in an explicit - `KNOWN_RESPONSE_GAPS` table that reports itself stale once the SDK catches up, - rather than being silently ignored. + object went undetected. Recursion surfaced the two gaps above, which are now + closed by adding the fields rather than suppressed, so `KNOWN_RESPONSE_GAPS` + is empty. It and its mirror `KNOWN_SHARED_MODEL_EXTRAS` both report an entry + as stale once the side they excuse catches up, instead of quietly keeping it. ## [3.4.0] - 2026-09-08 diff --git a/docs/models/answersearchresult.md b/docs/models/answersearchresult.md index 241633e..1b4ddd7 100644 --- a/docs/models/answersearchresult.md +++ b/docs/models/answersearchresult.md @@ -9,5 +9,7 @@ A web search result used during answer synthesis. |-------|------|----------|-------------| | `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | | `title` | *str* | :heavy_check_mark: | The title of the source webpage. | +| `description` | *Optional[str]* | :heavy_minus_sign: | A brief description of the content of the search result. | | `snippets` | Optional[List[*str*]] | :heavy_minus_sign: | Text snippets from the search result that preview its content. | +| `thumbnail_url` | *Optional[str]* | :heavy_minus_sign: | URL of the thumbnail. | | `page_age` | *Optional[str]* | :heavy_minus_sign: | The publication date or age supplied by the search result. | diff --git a/docs/models/financeresearchsource.md b/docs/models/financeresearchsource.md index 90a8e2a..5e4f9fc 100644 --- a/docs/models/financeresearchsource.md +++ b/docs/models/financeresearchsource.md @@ -3,7 +3,8 @@ ## Fields -| Field | Type | Required | Description | Example | -| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | https://investor.apple.com/sec-filings/annual-reports/default.aspx | -| `title` | *Optional[str]* | :heavy_minus_sign: | The title of the source webpage. | Apple Inc. Annual Report FY2024 (Form 10-K) | \ No newline at end of file +| Field | Type | Required | Description | Example | +|------------|-----------------------|--------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------| +| `url` | *str* | :heavy_check_mark: | The URL of the source webpage. | https://investor.apple.com/sec-filings/annual-reports/default.aspx | +| `title` | *Optional[str]* | :heavy_minus_sign: | The title of the source webpage. | Apple Inc. Annual Report FY2024 (Form 10-K) | +| `snippets` | Optional[List[*str*]] | :heavy_minus_sign: | Relevant excerpts from the source page that were used in generating the answer. | ["Record Q1 revenue of $124.3 billion", "Data center capex up 12%"] | diff --git a/scripts/check_drift.py b/scripts/check_drift.py index d93e472..bd0df9b 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -46,15 +46,17 @@ ("GET", "/v1/search"), } -# Nested response fields the SDK models don't define yet. These predate the -# response check learning to recurse (it previously compared top-level fields -# only, so drift inside a nested object was invisible). Keyed by -# (spec name, dotted field path from the response root). An entry that goes -# stale — the SDK catches up — is reported rather than silently ignored. -KNOWN_RESPONSE_GAPS = { - ("answer", "results.web"): {"description", "thumbnail_url"}, - ("finance-research", "output.sources"): {"snippets"}, -} +# Nested response fields the SDK models don't define yet. Keyed by (spec name, +# dotted field path from the response root). An entry that goes stale — the SDK +# catches up — is reported rather than silently ignored. +# +# Currently empty: the two gaps that recursion first surfaced +# (`AnswerSearchResult.description` / `.thumbnail_url` and +# `FinanceResearchSource.snippets`) were closed by adding the fields rather than +# suppressed. Prefer adding the field over suppressing it — the stale check can +# only see the SDK catching up, never the API starting to send a field the spec +# already promises, so a suppressed gap stays silent from that side forever. +KNOWN_RESPONSE_GAPS: dict[tuple[str, str], set[str]] = {} # The mirror image: fields an SDK model declares that the spec schema at *this* # path does not. The SDK shares one model across paths whose spec schemas differ diff --git a/src/youdotcom/models/answersearchresult.py b/src/youdotcom/models/answersearchresult.py index 3286c63..7882f3d 100644 --- a/src/youdotcom/models/answersearchresult.py +++ b/src/youdotcom/models/answersearchresult.py @@ -12,8 +12,14 @@ class AnswerSearchResult(BaseModel): title: str r"""The title of the source webpage.""" + description: Optional[str] = None + r"""A brief description of the content of the search result.""" + snippets: Optional[List[str]] = None r"""Text snippets from the search result that preview its content.""" + thumbnail_url: Optional[str] = None + r"""URL of the thumbnail.""" + page_age: Optional[str] = None r"""The publication date or age supplied by the search result.""" diff --git a/src/youdotcom/models/finance_researchop.py b/src/youdotcom/models/finance_researchop.py index 26c3325..5c30c03 100644 --- a/src/youdotcom/models/finance_researchop.py +++ b/src/youdotcom/models/finance_researchop.py @@ -141,6 +141,8 @@ class FinanceResearchSourceTypedDict(TypedDict): r"""The URL of the source webpage.""" title: NotRequired[str] r"""The title of the source webpage.""" + snippets: NotRequired[List[str]] + r"""Relevant excerpts from the source page that were used in generating the answer.""" class FinanceResearchSource(BaseModel): @@ -150,9 +152,12 @@ class FinanceResearchSource(BaseModel): title: Optional[str] = None r"""The title of the source webpage.""" + snippets: Optional[List[str]] = None + r"""Relevant excerpts from the source page that were used in generating the answer.""" + @model_serializer(mode="wrap") def serialize_model(self, handler): - optional_fields = set(["title"]) + optional_fields = set(["title", "snippets"]) serialized = handler(self) m = {} diff --git a/tests/mockserver/internal/handler/pathpostv1financeresearch.go b/tests/mockserver/internal/handler/pathpostv1financeresearch.go index 62941a1..08b9995 100644 --- a/tests/mockserver/internal/handler/pathpostv1financeresearch.go +++ b/tests/mockserver/internal/handler/pathpostv1financeresearch.go @@ -35,9 +35,9 @@ func pathPostV1FinanceResearch(dir *logging.HTTPFileDirectory, rt *tracking.Requ } } -// Finance Research sources intentionally never include the `snippets` field -// (FinanceResearchSource only defines `url` and `title`; extra fields are -// ignored by pydantic's default config). +// Finance Research sources include `snippets`: the spec defines it on +// output.sources[] items and FinanceResearchSource models it. Production was not +// returning it as of 3.5.0, so the mock emits it to keep the parse path covered. func testPostV1FinanceResearchSuccess(w http.ResponseWriter, req *http.Request) { if err := assert.SecurityHeader(req, "X-API-Key", false); err != nil { log.Printf("assertion error: %s\n", err) @@ -82,8 +82,9 @@ func testPostV1FinanceResearchSuccess(w http.ResponseWriter, req *http.Request) "content_type": "text", "sources": []map[string]interface{}{ { - "url": "https://investor.nvidia.com/financial-info/financial-reports/default.aspx", - "title": "NVIDIA Corporation - Financial Reports", + "url": "https://investor.nvidia.com/financial-info/financial-reports/default.aspx", + "title": "NVIDIA Corporation - Financial Reports", + "snippets": []string{"NVIDIA reported record full-year revenue."}, }, }, }, diff --git a/tests/test_answer.py b/tests/test_answer.py index 263fc25..1b18031 100644 --- a/tests/test_answer.py +++ b/tests/test_answer.py @@ -28,7 +28,7 @@ ], "results": { "web": [ - {"url": "https://example.com/quantum", "title": "Quantum News", "snippets": ["IBM announced a new processor."], "page_age": "2025-06-25T11:41:00"}, + {"url": "https://example.com/quantum", "title": "Quantum News", "description": "A brief description of the quantum result.", "snippets": ["IBM announced a new processor."], "thumbnail_url": "https://example.com/quantum.png", "page_age": "2025-06-25T11:41:00"}, {"url": "https://example.com/ibm", "title": "IBM Quantum", "snippets": ["Google achieved error correction."]}, ] }, @@ -84,6 +84,13 @@ def test_returns_answer_response(self): assert res.results.web[0].title == "Quantum News" assert res.results.web[0].page_age == "2025-06-25T11:41:00" assert res.results.web[1].page_age is None + # Both are defined by the answer spec and returned by prod on every web + # result; they were silently dropped at parse time until 3.5.0. + assert res.results.web[0].description == "A brief description of the quantum result." + assert res.results.web[0].thumbnail_url == "https://example.com/quantum.png" + # ...and optional: absent on the second result rather than raising. + assert res.results.web[1].description is None + assert res.results.web[1].thumbnail_url is None def test_posts_to_answer_endpoint(self): captured: dict = {} diff --git a/tests/test_research.py b/tests/test_research.py index 79cea1f..0c6abfa 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -212,12 +212,14 @@ def test_basic_finance_research(self, server_url, api_key): assert res.output is not None assert res.output.content is not None assert "NVIDIA" in res.output.content - # Finance sources intentionally never include the `snippets` field. + # The spec defines `snippets` on output.sources[]; it was silently + # dropped at parse time until 3.5.0. assert res.output.sources is not None assert len(res.output.sources) > 0 for source in res.output.sources: assert source.url is not None assert source.title is not None + assert source.snippets is not None def test_finance_research_unauthorized(self, server_url): client = create_test_http_client("post_/v1/finance_research-unauthorized") From 857efba0e4a6fd392fa1fd4600038f2871291bf5 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 21:38:49 -0700 Subject: [PATCH 13/24] test: guard optional res.results in the three knowledge live tests that lacked it (DX-835) `SearchResponse.results` is `Optional[Results]`, so `assert res.results.knowledge` raises AttributeError on a response that omits `results`, hiding the assertion that was actually meant to fail. Three of the six tests in TestLiveSearchKnowledge already asserted `res.results is not None` first; the other three did not. All six now do. Verified by parsing the class and confirming, per test, that the guard precedes the first dereference rather than just that both strings appear. 6 live knowledge tests pass against prod; 425 offline tests unchanged. --- tests/test_live.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_live.py b/tests/test_live.py index 1cd4506..183749d 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -422,12 +422,14 @@ def test_knowledge_accepts_plain_string(self, you_client): with you_client as you: res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core") + assert res.results is not None assert res.results.knowledge def test_knowledge_result_shape(self, you_client): with you_client as you: res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core") + assert res.results is not None assert res.results.knowledge for kr in res.results.knowledge: # ``type`` is a plain str so an unrecognized future kind parses @@ -451,6 +453,7 @@ def test_knowledge_within_documented_cap(self, you_client): with you_client as you: res = you.search(query=self.KNOWLEDGE_QUERY, knowledge="core", count=1) + assert res.results is not None assert res.results.knowledge assert len(res.results.knowledge) <= 25 From 9bfd953dfac26d58ab986fd90e2b3d2fc05b42ab Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 21:52:47 -0700 Subject: [PATCH 14/24] test: make the Contents live tests assert the formats they request (DX-835) Three of the four TestLiveContents cases could pass without the requested field ever arriving: test_html_format guarded with `if res[0].html:`, so a response carrying no html asserted nothing -- while its own comment said "HTML should be present when HTML format is requested" test_markdown_format asserted only that a non-empty list came back test_multiple_formats same; never checked html or markdown at all test_metadata_format in the same class already did this correctly (`assert res[0].metadata is not None`), so the other three now match it. Presence is asserted with `is not None` rather than truthiness, per AGENTS.md's rule for server-returned strings. Pre-existing, and untouched by this branch until now. Verified against prod after strengthening rather than assuming: all 5 Contents live tests pass, so the fields really do arrive and the assertions were simply missing. The full non-research live suite (31 tests) and the 425-test offline gate pass. --- tests/test_live.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_live.py b/tests/test_live.py index 183749d..10cbaf7 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -492,9 +492,10 @@ def test_html_format(self, you_client): assert isinstance(res, list) assert len(res) > 0 assert res[0].url is not None - # HTML should be present when HTML format is requested - if res[0].html: - assert "<" in res[0].html # Basic HTML check + # HTML must be present when the HTML format is requested; `if` would + # let a response with no html pass without asserting anything. + assert res[0].html is not None + assert "<" in res[0].html # Basic HTML check def test_markdown_format(self, you_client): """Test fetching content in Markdown format.""" @@ -506,6 +507,7 @@ def test_markdown_format(self, you_client): assert isinstance(res, list) assert len(res) > 0 + assert res[0].markdown is not None def test_metadata_format(self, you_client): """Test fetching metadata from a page.""" @@ -530,6 +532,9 @@ def test_multiple_formats(self, you_client): assert isinstance(res, list) assert len(res) > 0 + # Both requested formats must come back. + assert res[0].html is not None + assert res[0].markdown is not None @requires_api_key From 34bde1e21078487ea0a5ba4b20a0add83ec52f8f Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 22:08:30 -0700 Subject: [PATCH 15/24] docs: stop implying web and news results come back with knowledge (DX-835) The `knowledge` parameter description opened with "Requests knowledge results alongside web and news search", which reads as a promise that those sections accompany knowledge results. `SearchResponse.results` and each of its sections are optional, and this PR's own description records that prod currently drops `results.web` entirely when knowledge is requested. The docs and tests here deliberately make no claim about `results.web` alongside knowledge, so the parameter wording was the one surface still implying it. Reworded the opening to "Requests knowledge results from licensed data providers." -- accurate, matches how the CHANGELOG and USAGE already describe knowledge, and makes no claim about other sections. It is one character shorter than the sentence it replaces, so all three parameter tables keep their existing row padding; a longer rewrite would have forced re-padding every row of three tables for a wording change. Applied to the five SDK-authored surfaces the cross-table identity rule covers (both SearchRequestBody field docstrings, docs/sdks/search/README.md, docs/sdks/you/README.md, docs/models/searchrequestbody.md) and verified identical afterwards. SearchRequestBody.properties.knowledge carries no description in the published spec, so this text is entirely SDK-authored. Deliberately NOT applied to the two spec-verbatim surfaces: the Knowledge enum class docstring and docs/models/knowledge.md mirror components.schemas.Knowledge.description, which is exactly "Requests knowledge results alongside web and news search." AGENTS.md pins model docstrings to the spec, so those follow the spec rather than editorialising ahead of it; if the upstream wording changes they change with it. --- docs/models/searchrequestbody.md | 2 +- docs/sdks/search/README.md | 2 +- docs/sdks/you/README.md | 2 +- src/youdotcom/models/searchrequestbody.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/models/searchrequestbody.md b/docs/models/searchrequestbody.md index 54089c3..e572d3e 100644 --- a/docs/models/searchrequestbody.md +++ b/docs/models/searchrequestbody.md @@ -12,7 +12,7 @@ | `country` | [Optional[models.Country]](../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | -| `knowledge` | [Optional[models.Knowledge]](../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | +| `knowledge` | [Optional[models.Knowledge]](../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results from licensed data providers. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/docs/sdks/search/README.md b/docs/sdks/search/README.md index cdf4b6f..1c5e2b5 100644 --- a/docs/sdks/search/README.md +++ b/docs/sdks/search/README.md @@ -48,7 +48,7 @@ with You( | `country` | [Optional[models.Country]](../../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | -| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | +| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results from licensed data providers. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/docs/sdks/you/README.md b/docs/sdks/you/README.md index 3f67aa9..c0bbb94 100644 --- a/docs/sdks/you/README.md +++ b/docs/sdks/you/README.md @@ -263,7 +263,7 @@ with You( | `country` | [Optional[models.Country]](../../models/country.md) | :heavy_minus_sign: | The country code that determines the geographical focus of the web results. | | | `language` | [Optional[models.Language]](../../models/language.md) | :heavy_minus_sign: | The language of the web results that will be returned (BCP 47 format). | | | `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. | | -| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | +| `knowledge` | [Optional[models.Knowledge]](../../models/knowledge.md) | :heavy_minus_sign: | Requests knowledge results from licensed data providers. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge. | core | | `livecrawl` | [Optional[models.LiveCrawl]](../../models/livecrawl.md) | :heavy_minus_sign: | **Deprecated; use `extraction` instead.** Indicates which section(s) of search results to livecrawl and return full page content. | | | `livecrawl_formats` | Optional[List[[models.LiveCrawlFormats](../../models/livecrawlformats.md)]] | :heavy_minus_sign: | **Deprecated; use `extraction.full_page.extraction_formats` instead.** Indicates the format(s) of the livecrawled content. Pass one or both values (`html`, `markdown`). In a GET request, repeat the parameter: `?livecrawl_formats=html&livecrawl_formats=markdown`. In a POST body, provide a JSON array: `["html", "markdown"]`. | | | `extraction` | [Optional[models.Extraction]](../../models/extraction.md) | :heavy_minus_sign: | Controls how page content is attached to each result. Preferred over `livecrawl`/`livecrawl_formats`. The two are mutually exclusive; `you.search` raises `ValueError` if both are passed. Top-level `crawl_timeout` is invalid alongside `extraction_mode="highlights"` and is stripped from the body. | | diff --git a/src/youdotcom/models/searchrequestbody.py b/src/youdotcom/models/searchrequestbody.py index d7e3b1b..d2dbd7c 100644 --- a/src/youdotcom/models/searchrequestbody.py +++ b/src/youdotcom/models/searchrequestbody.py @@ -34,7 +34,7 @@ class SearchRequestBodyTypedDict(TypedDict): safesearch: NotRequired[SafeSearch] r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" knowledge: NotRequired[Knowledge] - r"""Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" + r"""Requests knowledge results from licensed data providers. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" livecrawl: NotRequired[LiveCrawl] r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" livecrawl_formats: NotRequired[List[LiveCrawlFormats]] @@ -91,7 +91,7 @@ class SearchRequestBody(BaseModel): r"""Configures the safesearch filter for content moderation. This allows you to decide whether to return NSFW content or not.""" knowledge: Optional[Knowledge] = None - r"""Requests knowledge results alongside web and news search. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" + r"""Requests knowledge results from licensed data providers. `"core"` is the only value the API accepts; anything else raises `ValidationError` locally, mirroring the server's `422`. Returns up to 25 results, limited to those relevant to the query — `count` caps the web and news sections, not knowledge.""" livecrawl: Optional[LiveCrawl] = None r"""Deprecated; use `extraction` instead. Indicates which section(s) of search results to livecrawl and return full page content.""" From 3fb67155a0fcdc05ae089a4f8409c9ff03d716e1 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Mon, 21 Sep 2026 22:23:53 -0700 Subject: [PATCH 16/24] fix: don't report a spec-dropped field as stale; reword the Knowledge page (DX-835) Two review findings. check_drift.py: `stale = known & model_fields` contradicted the comment sitting directly above it. A field the spec *dropped* is still in model_fields, so its suppression was reported stale even though the spec no longer defines it -- while the comment said that case "must not be reported as stale". Intersecting with `props` makes the code match the stated intent. Proven real rather than theoretical: with spec props {kept}, suppressed {added} and SDK fields {kept, added}, the old expression yields stale={added} and the new one yields empty. All three staleness cases now verified -- gap still open (not stale), SDK caught up (stale), spec dropped the field (not stale). The mirror table KNOWN_SHARED_MODEL_EXTRAS already intersected with props, so it was correct; the two directions differ and only this one was wrong. docs/models/knowledge.md: the H1 said knowledge is requested "alongside web and news search" -- the same composition promise removed from the parameter surfaces in 34bde1e. I declined to change this page then, on the grounds that it mirrors the spec-verbatim Knowledge enum docstring. That was over-broad. AGENTS.md's spec-verbatim rule binds model docstrings; docs pages carry their own prose. docs/models/extraction.md, the page AGENTS.md names as the format template, does not match the Extraction class docstring, and contentsmetadata.md uses a richer deprecation callout. Across all 65 model pages, 22 mirror their class docstring exactly once RST/markdown backtick dialect is normalised and 7 genuinely carry their own wording. So the page is reworded and the enum docstring stays verbatim. Worth noting: scripts/check_drift.py has no committed test coverage at all, which is why two bugs in this one function surfaced only through review. --- docs/models/knowledge.md | 2 +- scripts/check_drift.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/models/knowledge.md b/docs/models/knowledge.md index 4ac4e67..edc4403 100644 --- a/docs/models/knowledge.md +++ b/docs/models/knowledge.md @@ -1,6 +1,6 @@ # Knowledge -Requests knowledge results alongside web and news search. +Requests knowledge results from licensed data providers, returned under `response.results.knowledge` (omitted when none are relevant). ## Example Usage diff --git a/scripts/check_drift.py b/scripts/check_drift.py index bd0df9b..3e2e150 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -486,11 +486,12 @@ def _compare_response_fields( missing_in_spec = model_fields - set(props) known = KNOWN_RESPONSE_GAPS.get((spec_name, field_path), set()) - # Stale means the SDK model now defines the field, so the suppression no - # longer does anything. Compare against the model, not against what's - # missing: a field the spec dropped is neither missing nor defined, and - # must not be reported as stale. - stale = known & model_fields + # Stale means the suppression no longer does anything: the spec still + # defines the field AND the SDK now defines it too. Intersecting with + # `props` as well covers the third case — a field the spec dropped is + # neither missing from the SDK nor a live suppression, so it must not be + # reported as stale. + stale = known & model_fields & set(props) if stale: warnings.append( f"[response] {path}: KNOWN_RESPONSE_GAPS entry {stale} is stale — " From bdafc4a382ade6b7f1318d9723540aefad984e26 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 09:08:41 -0700 Subject: [PATCH 17/24] test: pin the drift checker's recursion and suppression rules (DX-835) scripts/check_drift.py had no committed test coverage, which is why two bugs in one function -- the `visited` global cache and the staleness intersection -- surfaced through review rather than through a failing test, and why each reproduction written while fixing them was thrown away afterwards. tests/test_check_drift.py loads the script by path (it is not a package module) and pins 22 cases: response recursion down through arrays and $refs, drift on a sibling branch that reuses a model, cycle safety for self-referential and mutual A->B->A schemas, oneOf bail-out, all three staleness cases for both suppression tables, the _resolve_schema and _nested_model helpers, and the shipped state of both tables. Both regressions were verified to actually fail when their fix is reverted and pass when it is restored, so these are regression tests rather than coverage for its own sake. The script is byte-identical afterwards. Offline gate 425 -> 447. tests/README.md inventory and group counts updated to match: 21 files listed, groups summing to exactly the CI-gate collection. --- tests/README.md | 9 +- tests/test_check_drift.py | 314 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 tests/test_check_drift.py diff --git a/tests/README.md b/tests/README.md index 77c321e..436ef55 100644 --- a/tests/README.md +++ b/tests/README.md @@ -69,6 +69,7 @@ pytest tests/ -v - `test_redaction.py` - Tests for debug-log header redaction - `test_client_lifecycle.py` - Tests for client teardown in `You.__exit__` / `You.__aexit__` - `test_root_init.py` - Tests for the `youdotcom` package root module +- `test_check_drift.py` - Regression tests for `scripts/check_drift.py` (response-schema recursion, cycle safety, suppression-table staleness) - `test_performance.py` - Performance/instrumentation tests measuring SDK overhead - `test_live.py` - Live API tests that run against the real You.com API (requires API key) @@ -77,7 +78,7 @@ pytest tests/ -v Tests are organized into logical classes using pytest: Counts below are collected tests (`pytest --collect-only`), so a parametrized case -counts once per parameter set. The groups sum to the 425 tests in the CI gate; +counts once per parameter set. The groups sum to the 447 tests in the CI gate; `test_performance.py` and `test_live.py` are excluded from that gate. **Search API** (10 tests): @@ -146,6 +147,12 @@ counts once per parameter set. The groups sum to the 425 tests in the CI gate; - Direct methods on `You` (8) and backward-compat sub-SDK shims (6) - `youdotcom` package root module (5) +**Drift checker** (22 tests): +- Response-schema recursion, including drift on a sibling branch that reuses a model +- Cycle safety for self-referential and mutual (`A.b -> B.a -> A`) schemas +- `KNOWN_RESPONSE_GAPS` and `KNOWN_SHARED_MODEL_EXTRAS` staleness in all three cases +- `_resolve_schema` and `_nested_model` helpers + **Outside the CI gate**: - `test_performance.py` (33 tests) - SDK overhead instrumentation - `test_live.py` (46 tests) - runs against the real API, requires an API key diff --git a/tests/test_check_drift.py b/tests/test_check_drift.py new file mode 100644 index 0000000..13b9f50 --- /dev/null +++ b/tests/test_check_drift.py @@ -0,0 +1,314 @@ +"""Regression tests for ``scripts/check_drift.py``. + +The script is not an importable package module, so it is loaded by path. These +tests pin the response-recursion and suppression rules that two separate review +rounds found bugs in: + +- ``_compare_response_fields`` treated ``visited`` as a global "already compared + this model" cache rather than a recursion stack, so a model reused at two + schema paths was compared only at the first and drift on later branches went + unreported. +- ``stale = known & model_fields`` reported a field the *spec* dropped as a stale + suppression, contradicting the comment directly above it. + +Neither was caught by a failing test, because the script had no test coverage at +all. Every case below was first reproduced by hand while fixing the bug. +""" + +import importlib.util +from pathlib import Path +from typing import List, Optional + +import pytest + +from youdotcom.types import BaseModel + +_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check_drift.py" + + +@pytest.fixture(scope="module") +def cd(): + """The drift checker, loaded by path (it is a script, not a package module).""" + spec = importlib.util.spec_from_file_location("check_drift_under_test", _SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture(autouse=True) +def _restore_suppression_tables(cd): + """Tests mutate the suppression tables; put them back afterwards.""" + gaps = dict(cd.KNOWN_RESPONSE_GAPS) + extras = dict(cd.KNOWN_SHARED_MODEL_EXTRAS) + yield + cd.KNOWN_RESPONSE_GAPS.clear() + cd.KNOWN_RESPONSE_GAPS.update(gaps) + cd.KNOWN_SHARED_MODEL_EXTRAS.clear() + cd.KNOWN_SHARED_MODEL_EXTRAS.update(extras) + + +# --------------------------------------------------------------------------- +# Models and spec builders +# --------------------------------------------------------------------------- + + +class Item(BaseModel): + title: Optional[str] = None + + +class Nested(BaseModel): + web: Optional[List[Item]] = None + + +class Root(BaseModel): + results: Optional[Nested] = None + + +class Node(BaseModel): + name: Optional[str] = None + child: Optional["Node"] = None + + +class Branch(BaseModel): + a: Optional["Trunk"] = None + + +class Trunk(BaseModel): + b: Optional[Branch] = None + + +Node.model_rebuild() +Branch.model_rebuild() +Trunk.model_rebuild() + + +def _spec(schemas: dict) -> dict: + return {"components": {"schemas": schemas}} + + +def _obj(props: dict) -> dict: + return {"type": "object", "properties": props} + + +def _ref(name: str) -> dict: + return {"$ref": f"#/components/schemas/{name}"} + + +def _array(items: dict) -> dict: + return {"type": "array", "items": items} + + +def _run(cd, schema, model, spec, *, spec_name="demo", field_path="", path="resp"): + warnings: list = [] + cd._compare_response_fields( + schema, model, path, spec, warnings, set(), spec_name, field_path + ) + return warnings + + +# --------------------------------------------------------------------------- +# Recursion +# --------------------------------------------------------------------------- + + +class TestRecursion: + def test_drift_nested_inside_an_array_is_caught(self, cd): + """The case that motivated the recursion: a new field two levels down, + inside an array, where the top-level object is unchanged.""" + spec = _spec({ + "Item": _obj({"title": {"type": "string"}, "brand_new": {"type": "string"}}), + "Nested": _obj({"web": _array(_ref("Item"))}), + "Root": _obj({"results": _ref("Nested")}), + }) + w = _run(cd, _ref("Root"), Root, spec) + assert any("brand_new" in x for x in w), w + + def test_top_level_only_comparison_would_miss_it(self, cd): + """Guard the premise: with only top-level props compared, nothing inside + `results` is visible. Keeps the recursion honest about what it adds.""" + spec = _spec({ + "Item": _obj({"title": {"type": "string"}, "brand_new": {"type": "string"}}), + "Nested": _obj({"web": _array(_ref("Item"))}), + "Root": _obj({"results": _ref("Nested")}), + }) + top_level = set(spec["components"]["schemas"]["Root"]["properties"]) + assert top_level == {"results"} + assert "brand_new" not in top_level + + def test_oneof_union_bails_instead_of_reporting_everything_missing(self, cd): + """A union response has no `properties` of its own. Bailing is correct; + reporting every SDK field as absent from the spec would be noise.""" + spec = _spec({"Root": {"oneOf": [_ref("A"), _ref("B")]}}) + w = _run(cd, _ref("Root"), Root, spec) + assert w == [] + + +class TestSiblingBranches: + """Regression: `visited` used to be a global cache, not a recursion stack.""" + + def test_same_model_at_two_paths_is_compared_at_both(self, cd): + class TwoPaths(BaseModel): + left: Optional[Item] = None + right: Optional[Item] = None + + TwoPaths.model_rebuild() + spec = _spec({ + "Left": _obj({"title": {"type": "string"}}), + # `right` gains a field the SDK model does not have. + "Right": _obj({"title": {"type": "string"}, "only_on_right": {"type": "string"}}), + "TwoPaths": _obj({"left": _ref("Left"), "right": _ref("Right")}), + }) + w = _run(cd, _ref("TwoPaths"), TwoPaths, spec) + assert any("only_on_right" in x for x in w), ( + "drift on the second branch was skipped — `visited` is behaving as a " + "global cache again" + ) + + def test_self_referential_schema_terminates(self, cd): + """Cycle safety must survive dropping the global cache.""" + spec = _spec({ + "Node": _obj({ + "name": {"type": "string"}, + "child": _ref("Node"), + "node_only": {"type": "string"}, + }), + }) + w = _run(cd, _ref("Node"), Node, spec) + assert any("node_only" in x for x in w), w + + def test_mutual_cycle_terminates_and_still_reports(self, cd): + spec = _spec({ + "Trunk": _obj({"b": _ref("Branch")}), + "Branch": _obj({"a": _ref("Trunk"), "branch_only": {"type": "string"}}), + }) + w = _run(cd, _ref("Trunk"), Trunk, spec) + assert any("branch_only" in x for x in w), w + + +# --------------------------------------------------------------------------- +# Schema resolution helpers +# --------------------------------------------------------------------------- + + +class TestResolveSchema: + def test_follows_ref_then_array_items(self, cd): + spec = _spec({"Inner": _obj({"x": {"type": "string"}})}) + resolved = cd._resolve_schema(_array(_ref("Inner")), spec) + assert "x" in resolved.get("properties", {}) + + def test_breaks_a_ref_cycle(self, cd): + spec = _spec({"A": _obj({"b": _ref("B")}), "B": _obj({"a": _ref("A")})}) + # Must return rather than recurse forever; the exact result is a bail-out. + assert isinstance(cd._resolve_schema(_ref("A"), spec), dict) + + def test_self_ref_cycle_returns_empty(self, cd): + spec = _spec({"Loop": _ref("Loop")}) + assert cd._resolve_schema(_ref("Loop"), spec) == {} + + +class TestNestedModel: + def test_unwraps_optional_and_list(self, cd): + assert cd._nested_model(Optional[List[Item]]) is Item + + def test_bare_model(self, cd): + assert cd._nested_model(Item) is Item + + def test_scalar_has_no_nested_model(self, cd): + assert cd._nested_model(Optional[str]) is None + + +# --------------------------------------------------------------------------- +# Suppression tables +# --------------------------------------------------------------------------- + + +class TestKnownResponseGaps: + """`KNOWN_RESPONSE_GAPS` excuses a field the spec defines and the SDK lacks.""" + + def _spec_with(self, props): + return _spec({"Item": _obj({p: {"type": "string"} for p in props})}) + + def test_suppresses_a_known_gap(self, cd): + cd.KNOWN_RESPONSE_GAPS[("demo", "")] = {"ghost"} + w = _run(cd, _ref("Item"), Item, self._spec_with(["title", "ghost"])) + assert not any("ghost" in x for x in w), w + + def test_gap_still_open_is_not_stale(self, cd): + """Case 1: spec defines it, SDK does not — a live suppression.""" + cd.KNOWN_RESPONSE_GAPS[("demo", "")] = {"ghost"} + w = _run(cd, _ref("Item"), Item, self._spec_with(["title", "ghost"])) + assert not any("is stale" in x for x in w), w + + def test_stale_once_the_sdk_catches_up(self, cd): + """Case 2: both sides define it, so the entry no longer does anything.""" + cd.KNOWN_RESPONSE_GAPS[("demo", "")] = {"title"} + w = _run(cd, _ref("Item"), Item, self._spec_with(["title"])) + assert any("is stale" in x for x in w), w + + def test_not_stale_when_the_spec_drops_the_field(self, cd): + """Case 3 — regression. The spec no longer defines the field, so the + entry is not excusing anything and must not be reported stale.""" + cd.KNOWN_RESPONSE_GAPS[("demo", "")] = {"title"} + w = _run(cd, _ref("Item"), Item, self._spec_with(["other"])) + assert not any("is stale" in x for x in w), w + + def test_unsuppressed_gap_is_still_reported(self, cd): + w = _run(cd, _ref("Item"), Item, self._spec_with(["title", "ghost"])) + assert any("ghost" in x for x in w), w + + +class TestKnownSharedModelExtras: + """The mirror table: a field the SDK declares that the spec at *this* path + does not, because one model serves paths with differently-shaped schemas.""" + + def _spec_with(self, props): + return _spec({"Item": _obj({p: {"type": "string"} for p in props})}) + + def test_suppresses_the_extra_field(self, cd): + class Wide(BaseModel): + title: Optional[str] = None + extra: Optional[str] = None + + Wide.model_rebuild() + cd.KNOWN_SHARED_MODEL_EXTRAS[("demo", "")] = {"extra"} + w = _run(cd, _ref("Item"), Wide, self._spec_with(["title"])) + assert not any("extra" in x for x in w), w + + def test_stale_once_the_spec_defines_it(self, cd): + class Wide(BaseModel): + title: Optional[str] = None + extra: Optional[str] = None + + Wide.model_rebuild() + cd.KNOWN_SHARED_MODEL_EXTRAS[("demo", "")] = {"extra"} + w = _run(cd, _ref("Item"), Wide, self._spec_with(["title", "extra"])) + assert any("is stale" in x for x in w), w + + def test_unsuppressed_extra_is_still_reported(self, cd): + class Wide(BaseModel): + title: Optional[str] = None + extra: Optional[str] = None + + Wide.model_rebuild() + w = _run(cd, _ref("Item"), Wide, self._spec_with(["title"])) + assert any("extra" in x for x in w), w + + +# --------------------------------------------------------------------------- +# The real tables shipped with the SDK +# --------------------------------------------------------------------------- + + +class TestShippedTables: + def test_known_response_gaps_is_empty(self, cd): + """Both gaps this table once held were closed by adding the fields, so + nothing should be suppressed any more. If this fails, a new gap was + suppressed instead of fixed — say so in the PR rather than hiding it.""" + assert cd.KNOWN_RESPONSE_GAPS == {} + + def test_shared_model_extras_entries_are_current(self, cd): + """Each entry must still describe a real asymmetry: the SDK model defines + the field and the spec schema at that path does not.""" + assert cd.KNOWN_SHARED_MODEL_EXTRAS == { + ("web-search", "results.news.contents"): {"highlights"}, + } From 697694b0710dbc0da349299937f2d00405e3b843 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 09:53:17 -0700 Subject: [PATCH 18/24] feat: add a live wire audit; document a research response field; tighten two weak live tests (DX-835) check_drift.py compares the published specs against the SDK models. That cannot see a field the API returns but no spec declares -- which is exactly how AnswerSearchResult came to drop `description` and `thumbnail_url` while every static check agreed with itself and reported no drift. scripts/audit_wire.py closes that gap: it walks the raw JSON from a live call next to the parsed model and reports any key the model discarded. Across all seven endpoints that is 228 wire keys, none unexplained. It needs YDC_API_KEY so it is a pre-release check, not a CI gate; --strict exits 1 on an unexplained drop, negative-tested by emptying the known list. Two keys are recorded in KNOWN_WIRE_EXTRAS as observed-but-undeclared rather than modeled, so the tool stays quiet about decisions instead of hiding accidents: results.web[].original_thumbnail_url declared by no published spec finance-research root `warnings` declared by the sibling research spec and sent by prod as [], but absent from finance-research.json `warnings` is the interesting one. Adding it to FinanceResearchResponse is tempting -- ResearchResponse has it and prod sends it -- but finance-research.json declares only `output`, so the field would put the SDK permanently ahead of that endpoint's contract and leave a standing drift warning. Modeled it, watched check_drift --strict fail on exactly that, and reverted. The rule that falls out is the one already applied to snippets: add the field when the endpoint's spec declares it, record it when only prod does. A test pins the current state so closing the gap later is a deliberate act. Also here: - docs/models/researchresponse.md never documented `warnings`, though the field has always existed and parsed. Found by a missing-row audit; the type-cell audit could not see it because it only validates rows that already exist. - tests/test_live.py TestLiveSearch.test_search_with_filters guarded its only substantive assertion behind `if res.results.web:`, so an empty response passed and the filters went unverified despite the name. Now asserts the section is populated; verified against prod over five consecutive runs. - two pre-existing mypy errors in check_drift.py, from indexing a heterogeneous list of dicts, so `mypy src/youdotcom/ scripts/` is clean at 87 files. Offline gate 425 -> 450. tests/README.md counts updated to match. --- CHANGELOG.md | 22 ++++ docs/models/researchresponse.md | 7 +- scripts/audit_wire.py | 204 ++++++++++++++++++++++++++++++++ scripts/check_drift.py | 6 +- tests/README.md | 5 +- tests/test_live.py | 8 +- tests/test_research.py | 40 +++++++ 7 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 scripts/audit_wire.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 26a9b7f..50b5c38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ additive — no breaking changes. the time of this release, so nothing was being lost yet, but the model now matches the published contract instead of relying on a drift-checker suppression that could not have noticed the API starting to send it. +- **`docs/models/researchresponse.md` never documented `warnings`** — the field + exists on `ResearchResponse` and has always parsed correctly; only the docs + page was missing its row. ### Changed @@ -57,6 +60,25 @@ additive — no breaking changes. closed by adding the fields rather than suppressed, so `KNOWN_RESPONSE_GAPS` is empty. It and its mirror `KNOWN_SHARED_MODEL_EXTRAS` both report an entry as stale once the side they excuse catches up, instead of quietly keeping it. +- **`scripts/check_drift.py` recursion hardened** — `visited` was a global + "already compared this model" cache, so a model reused at two response paths + backed by different schemas was compared only at the first and drift on later + branches went unreported. It is now a recursion stack, discarded on exit, so + cycles still terminate without suppressing sibling branches. The staleness + check also intersected only with the SDK's fields, which reported a field the + *spec* dropped as stale — contradicting the comment above it — and now requires + the spec to still define the field. Both were found in review, and both are + covered by `tests/test_check_drift.py`, the first test coverage that script has + had. +- **`scripts/audit_wire.py` (new)** — walks the raw JSON from a live call next to + the parsed model and reports any key the model discarded. `check_drift.py` + compares the published specs against the models, which cannot see a field the + API returns but no spec declares; that is how `AnswerSearchResult` came to drop + `description` and `thumbnail_url` unnoticed. Needs `YDC_API_KEY`, so it is a + pre-release check rather than a CI gate. Two keys are recorded in + `KNOWN_WIRE_EXTRAS` as observed-but-undeclared rather than modeled: + `results.web[].original_thumbnail_url`, and finance-research's top-level + `warnings`, which the sibling research spec does declare. ## [3.4.0] - 2026-09-08 diff --git a/docs/models/researchresponse.md b/docs/models/researchresponse.md index e46094c..e4f2a62 100644 --- a/docs/models/researchresponse.md +++ b/docs/models/researchresponse.md @@ -3,6 +3,7 @@ ## Fields -| Field | Type | Required | Description | -| ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------ | -| `output` | [models.Output](../models/output.md) | :heavy_check_mark: | The research output containing the answer and sources. | \ No newline at end of file +| Field | Type | Required | Description | +|------------|--------------------------------------|--------------------|---------------------------------------------------------------------------------------------------------------------------------| +| `output` | [models.Output](../models/output.md) | :heavy_check_mark: | The research output containing the answer and sources. | +| `warnings` | Optional[List[*str*]] | :heavy_minus_sign: | A list of warnings generated during research, such as source access issues or partial results. Empty when no warnings occurred. | \ No newline at end of file diff --git a/scripts/audit_wire.py b/scripts/audit_wire.py new file mode 100644 index 0000000..4c7e885 --- /dev/null +++ b/scripts/audit_wire.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +""" +Wire audit: compares what the live API actually returns against what the SDK models keep. + +`scripts/check_drift.py` compares the published OpenAPI specs against the SDK +models. That cannot see a field the API returns but no spec declares -- the model +drops it at parse time and every static check still agrees with itself. This +script closes that gap by walking the raw JSON from prod next to the parsed model +and reporting any key the model discarded. + +It found three real drops that spec-vs-model comparison structurally could not: +`AnswerSearchResult.description` / `.thumbnail_url` (both since fixed) and +`WebResult.original_thumbnail_url` (still undeclared by any spec; see +KNOWN_WIRE_EXTRAS). + +Requires a real API key, so it cannot gate pull requests. Run it before a release +or after any change to a response model: + + YDC_API_KEY=... python scripts/audit_wire.py + YDC_API_KEY=... python scripts/audit_wire.py --fast # skip research calls + YDC_API_KEY=... python scripts/audit_wire.py --strict # exit 1 on a drop + +Exit codes: + 0 every wire key was kept by a model field (or is a known extra) + 1 an unexplained key was dropped (--strict only) + 2 no API key, or a call failed -- environment problem, not a finding + 3 the audit itself failed to run -- a bug, not a finding +""" + +import argparse +import json +import os +import re +import sys +import traceback +from typing import Any, Optional + +import httpx + +from youdotcom import You + +# Keys prod returns that no published spec declares, so no SDK model defines +# them. Deliberately not modeled: this repo grounds response models in the spec, +# and adding a field only observed on the wire would make the model authoritative +# for behavior the contract does not promise. Reported here rather than silently +# so the list stays a decision rather than an accident. Remove an entry once the +# spec declares the field and the model catches up. +# +# Keyed by (call label, dotted path with list indices normalized to []). +KNOWN_WIRE_EXTRAS = { + # Observed on web results; absent from web-search.json and every other + # published spec. Looks like a spec omission worth reporting upstream. + ("search:extraction-highlights", "results.web[].original_thumbnail_url"), + # Prod sends `warnings` at the top level of the finance-research response, and + # the sibling research spec declares it (ResearchResponse models it), but + # finance-research.json declares only `output`. Modeling it anyway would put + # the SDK permanently ahead of that endpoint's published contract and leave a + # standing drift warning, so it is recorded here instead. Observed as `[]`. + # Add the field once finance-research.json declares it -- the sibling's + # wording is the one to copy. Path is `root.` because this response is walked + # from its top level rather than from a named section. + ("finance-research", "root.warnings"), +} + + +class _Spy(httpx.BaseTransport): + """Wrap a transport and keep the last decoded JSON response body.""" + + def __init__(self, inner: httpx.BaseTransport): + self.inner = inner + self.last: Any = None + + def handle_request(self, request: httpx.Request) -> httpx.Response: + response = self.inner.handle_request(request) + try: + body = response.read() + self.last = json.loads(body) + response._content = body + except Exception: # non-JSON or unreadable; nothing to audit + self.last = None + return response + + +def _json_keys(model: Any) -> dict[str, str]: + """Map the JSON key a model field reads to its attribute name.""" + return {(f.alias or name): name for name, f in type(model).model_fields.items()} + + +def _normalize(path: str) -> str: + return re.sub(r"\[\d+\]", "[]", path) + + +def _walk(raw: Any, parsed: Any, path: str, dropped: list, kept: list) -> None: + """Compare a raw JSON node against the model it parsed into, recursively.""" + if isinstance(raw, dict): + if parsed is None or not hasattr(type(parsed), "model_fields"): + return + keys = _json_keys(parsed) + for key, value in raw.items(): + if key not in keys: + dropped.append((path, key, sorted(keys))) + continue + kept.append(f"{path}.{key}") + _walk(value, getattr(parsed, keys[key], None), f"{path}.{key}", dropped, kept) + elif isinstance(raw, list) and isinstance(parsed, list): + for index, (raw_item, parsed_item) in enumerate(zip(raw, parsed)): + _walk(raw_item, parsed_item, f"{path}[{index}]", dropped, kept) + + +def _audit(label: str, call, root: Optional[str], api_key: str) -> tuple[list, list]: + spy = _Spy(httpx.HTTPTransport()) + client = httpx.Client(transport=spy) + dropped: list = [] + kept: list = [] + try: + with You(api_key_auth=api_key, timeout_ms=300_000, client=client) as you: + parsed = call(you) + if spy.last is None: + raise RuntimeError(f"{label}: no JSON response captured") + raw = spy.last[root] if root else spy.last + model = getattr(parsed, root, None) if root else parsed + _walk(raw, model, root or "root", dropped, kept) + finally: + client.close() + return dropped, kept + + +SEARCH = "what is the capital of France" +RESEARCH_INPUT = "What drove NVIDIA data center revenue in fiscal 2025?" + + +def _calls(fast: bool) -> list: + out = [ + ("search:plain", lambda y: y.search(query=SEARCH, count=3), "results"), + ("search:knowledge-core", lambda y: y.search(query=SEARCH, count=3, knowledge="core"), "results"), + ("search:extraction-highlights", lambda y: y.search( + query="latest advances in fusion energy research", count=3, + extraction={"extraction_mode": "highlights"}), "results"), + ("answer", lambda y: y.answer(query="What caused the 2008 financial crisis?"), None), + ("contents", lambda y: y.contents( + urls=["https://example.com"], formats=["html", "markdown"]), None), + ] + if not fast: + # research endpoints take tens of seconds to minutes each + out += [ + ("research", lambda y: y.research(input=RESEARCH_INPUT, research_effort="standard"), None), + ("finance-research", lambda y: y.finance_research(input=RESEARCH_INPUT), None), + ] + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("--strict", action="store_true", help="exit 1 on an unexplained drop") + parser.add_argument("--fast", action="store_true", help="skip the slow research endpoints") + parser.add_argument("--verbose", action="store_true", help="list kept keys too") + args = parser.parse_args() + + api_key = os.getenv("YDC_API_KEY") or os.getenv("YOU_API_KEY_AUTH") + if not api_key: + print("YDC_API_KEY is not set; this audit calls the live API.") + return 2 + + try: + unexplained: list = [] + total_kept = 0 + for label, call, root in _calls(args.fast): + try: + dropped, kept = _audit(label, call, root, api_key) + except Exception as exc: + print(f" {label}: call failed ({type(exc).__name__}: {exc})") + return 2 + total_kept += len(kept) + known = [(p, k) for p, k, _ in dropped if (label, _normalize(f"{p}.{k}")) in KNOWN_WIRE_EXTRAS] + new = [d for d in dropped if (label, _normalize(f"{d[0]}.{d[1]}")) not in KNOWN_WIRE_EXTRAS] + status = "ok" if not new else f"{len(new)} DROPPED" + print(f" [{status:>10}] {label}: kept {len(kept)} wire keys" + + (f", {len(known)} known-undeclared" if known else "")) + if args.verbose: + for path, key, fields in new: + print(f" {path}.{key} (model has: {fields})") + else: + for path, key, fields in new: + print(f" ! {_normalize(path)}.{key} not modeled; model has {fields}") + unexplained += [(label, path, key) for path, key, _ in new] + + print(f"\nAudited {len(_calls(args.fast))} live calls, {total_kept} wire keys walked.") + if unexplained: + print(f"DROPPED: {len(unexplained)} key(s) the API returned and no model kept:") + for label, path, key in unexplained: + print(f" {label}: {_normalize(path)}.{key}") + print("\nEither the model is missing a field, or the key is undeclared by every") + print("spec and belongs in KNOWN_WIRE_EXTRAS with a reason.") + return 1 if args.strict else 0 + print("RESULT: no_unexplained_drops") + return 0 + except Exception: + traceback.print_exc() + print("RESULT: audit_failed") + return 3 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_drift.py b/scripts/check_drift.py index 3e2e150..d1be04b 100644 --- a/scripts/check_drift.py +++ b/scripts/check_drift.py @@ -27,7 +27,7 @@ import re import sys import traceback -from typing import Any +from typing import Any, cast import httpx @@ -397,7 +397,7 @@ def check_request_params(specs: dict[str, dict[str, Any]]) -> list[str]: continue spec_params = _get_schema_properties(schema, spec) - sdk_params = _get_sdk_method_params(check["sdk_method"]) + sdk_params = _get_sdk_method_params(cast(str, check["sdk_method"])) missing_in_sdk = spec_params - sdk_params missing_in_spec = sdk_params - spec_params @@ -590,7 +590,7 @@ def check_response_schemas(specs: dict[str, dict[str, Any]]) -> list[str]: continue spec_fields = _get_schema_properties(schema, spec) - sdk_fields = _get_sdk_model_fields(check["sdk_response_models"]) + sdk_fields = _get_sdk_model_fields(cast(list[str], check["sdk_response_models"])) missing_in_sdk = spec_fields - sdk_fields missing_in_spec = sdk_fields - spec_fields diff --git a/tests/README.md b/tests/README.md index 436ef55..000ae15 100644 --- a/tests/README.md +++ b/tests/README.md @@ -78,7 +78,7 @@ pytest tests/ -v Tests are organized into logical classes using pytest: Counts below are collected tests (`pytest --collect-only`), so a parametrized case -counts once per parameter set. The groups sum to the 447 tests in the CI gate; +counts once per parameter set. The groups sum to the 450 tests in the CI gate; `test_performance.py` and `test_live.py` are excluded from that gate. **Search API** (10 tests): @@ -116,13 +116,14 @@ counts once per parameter set. The groups sum to the 447 tests in the CI gate; - Async answer - Error handling (unauthorized, forbidden, payment required, unprocessable, internal server error) -**Research API** (34 tests): +**Research API** (37 tests): - Basic research functionality (standard, deep, exhaustive effort) - Background mode (task submission, get_research_task, status polling) - Output schema (structured JSON output, content_type object) - Source control (include/exclude/boost domains, freshness, country) - Error handling (unauthorized, forbidden, unprocessable entity, 422 combos) - Stream research task (SSE success path + 404/401/403 error paths) +- Response envelope `warnings`, and the finance-research gap recorded against it **Research Helpers** (57 tests): - research_background / research_background_async (TaskResponse return) diff --git a/tests/test_live.py b/tests/test_live.py index 10cbaf7..9e850f8 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -117,9 +117,11 @@ def test_search_with_filters(self, you_client): assert res.results is not None assert res.metadata is not None - # Verify we got results - if res.results.web: - assert len(res.results.web) <= 5 + # Assert the section is actually populated. The previous `if` guard + # let an empty response pass, so neither the filters nor the count cap + # were verified despite the test name. + assert res.results.web, "expected web results for a broad recent query" + assert len(res.results.web) <= 5 @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_search_with_livecrawl_web(self, you_client): diff --git a/tests/test_research.py b/tests/test_research.py index 0c6abfa..be0c4a8 100644 --- a/tests/test_research.py +++ b/tests/test_research.py @@ -18,6 +18,7 @@ ) from youdotcom.models import ( FinanceResearchEffort, + FinanceResearchResponse, ResearchEffort, ResearchResponse, TaskResponse, @@ -650,3 +651,42 @@ def handler(request): research_effort=FinanceResearchEffort.DEEP, ) await sdk_async_client.aclose() + + +class TestResponseWarnings: + """`warnings` is a top-level field on the research response envelope. + + Prod also sends `warnings` on finance_research, and the sibling research spec + declares it, but finance-research.json declares only `output`. The SDK models + what that endpoint's published spec declares, so `FinanceResearchResponse` + has no `warnings` field. The difference is recorded in + ``scripts/audit_wire.py``'s ``KNOWN_WIRE_EXTRAS`` rather than modeled ahead of + the contract, which would leave a permanent drift warning. + """ + + _OUTPUT = { + "content": "x", + "content_type": "text", + "sources": [{"url": "https://e.com", "title": "t"}], + } + + def test_research_parses_warnings(self): + res = ResearchResponse.model_validate( + {"output": self._OUTPUT, "warnings": ["partial results"]} + ) + assert res.warnings == ["partial results"] + + def test_research_warnings_are_optional(self): + res = ResearchResponse.model_validate({"output": self._OUTPUT}) + assert res.warnings is None + + def test_finance_research_does_not_model_warnings(self): + """Deliberate, not an oversight — see the class docstring. + + If finance-research.json ever declares `warnings`: add the field to + ``FinanceResearchResponse`` and its TypedDict, add the row to + ``docs/models/financeresearchresponse.md``, and remove the + ``KNOWN_WIRE_EXTRAS`` entry in ``scripts/audit_wire.py``. This test fails + until all of that is done. + """ + assert "warnings" not in FinanceResearchResponse.model_fields From e7bdd0be177b298469cb227aa0debb789ef1b088 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 10:08:58 -0700 Subject: [PATCH 19/24] fix: reword the knowledge param docstring; close the audit script's transport (DX-835) Two review findings. sdk.py's `_search_impl` `:param knowledge:` still said knowledge is returned "alongside web and news search" -- the same composition promise removed from the five parameter surfaces in 34bde1e. It survived because that sweep grepped line by line and this copy wraps across two lines. A whitespace-normalised search over src/, docs/, README, USAGE and CHANGELOG now finds the phrase in exactly one place: the Knowledge enum docstring, which stays verbatim against components.schemas.Knowledge.description. scripts/audit_wire.py's _Spy wrapped an httpx.HTTPTransport but never closed it. BaseTransport.close() is a no-op and Client.close() only calls the outer transport, so the inner pool leaked once per audited call -- seven times on a full run. Added close() delegating inward, and verified both directions: with the override the inner transport's close() is called exactly once, and a control spy without it is called zero times. --- scripts/audit_wire.py | 10 +++++++++- src/youdotcom/sdk.py | 7 ++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/scripts/audit_wire.py b/scripts/audit_wire.py index 4c7e885..219b016 100644 --- a/scripts/audit_wire.py +++ b/scripts/audit_wire.py @@ -64,7 +64,12 @@ class _Spy(httpx.BaseTransport): - """Wrap a transport and keep the last decoded JSON response body.""" + """Wrap a transport and keep the last decoded JSON response body. + + ``BaseTransport.close()`` is a no-op and ``Client.close()`` only calls the + outer transport, so the wrapped transport has to be closed explicitly or + every call in a run leaks its connection pool. + """ def __init__(self, inner: httpx.BaseTransport): self.inner = inner @@ -80,6 +85,9 @@ def handle_request(self, request: httpx.Request) -> httpx.Response: self.last = None return response + def close(self) -> None: + self.inner.close() + def _json_keys(model: Any) -> dict[str, str]: """Map the JSON key a model field reads to its attribute name.""" diff --git a/src/youdotcom/sdk.py b/src/youdotcom/sdk.py index 5743e02..585b673 100644 --- a/src/youdotcom/sdk.py +++ b/src/youdotcom/sdk.py @@ -1033,9 +1033,10 @@ def _search_impl( :param language: BCP 47 language code. Omit the argument to use the API default (``"en"``); pass ``None`` to send no language at all. :param safesearch: ``"strict"``, ``"moderate"``, or ``"off"``. - :param knowledge: ``"core"`` -- requests knowledge results alongside - web and news search. Omit to skip them. It is the only value the - API accepts; anything else raises + :param knowledge: ``"core"`` -- requests knowledge results (cards backed + by licensed data providers), returned under + ``response.results.knowledge`` when relevant. Omit to skip them. It + is the only value the API accepts; anything else raises :class:`pydantic.ValidationError` locally, mirroring the server's ``422``. :param livecrawl: deprecated. ``"web"``, ``"news"``, or ``"all"``. From e19746e9193b743cef3b1d993a511e99a93efa77 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 10:41:48 -0700 Subject: [PATCH 20/24] docs: state that every Results section is optional and show the guard pattern (DX-835) docs/models/results.md was a bare field table with no prose, unlike its sibling searchresponse.md. That matters here: all three sections are Optional and independent, `results` itself is Optional, and prod currently omits `web` entirely whenever knowledge is requested -- returning a completely empty results object for roughly half the queries tested. A user reading the field table alone had nothing telling them to guard each level. Added the contract statement and a runnable example. The example was executed verbatim against prod on a query that returns no `web` section: it parses, meets the copy-paste-runnable and timeout_ms rules, and runs clean. The wording describes optionality, which the schema guarantees and which stays true however the server-side issue resolves. It deliberately does not say "`web` is omitted when knowledge is requested" -- that would document a bug as contract, be wrong once fixed, and nothing would catch the staleness, because check_drift.py compares field names, enums, endpoints and servers but never descriptions. --- docs/models/results.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/models/results.md b/docs/models/results.md index a033b95..244fbcd 100644 --- a/docs/models/results.md +++ b/docs/models/results.md @@ -1,5 +1,26 @@ # Results +The container for a search response's sections. All three are optional and +independent of each other: the API includes a key only when it has something to +put in it, so any of `web`, `news`, and `knowledge` may be **absent** rather than +present-and-empty, and `results` itself may be omitted. Guard each level before +iterating. + +## Example Usage + +```python +import os +from youdotcom import You + +with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: + res = you.search(query="what is the capital of France", knowledge="core") + +if res.results: + for hit in res.results.web or []: + print(hit.title, hit.url) + for card in res.results.knowledge or []: + print(card.title) +``` ## Fields From 6b32f6c49cd4fde82a57093091791e8951fa5592 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 10:53:46 -0700 Subject: [PATCH 21/24] docs: make audit_wire's --verbose help describe what it does (DX-835) The help said "list kept keys too", but the flag never printed kept keys -- both branches print dropped keys and differ only in path normalization. Reworded to describe the real behavior and why the default is the more useful one: the normalized form (results.web[].x) is what matches KNOWN_WIRE_EXTRAS, while --verbose preserves list indices (results.web[0].x) for locating a specific item. Verified against a run with the known-extras list emptied, so the dropped-key paths are actually printed in both modes: default normalizes, --verbose does not. --- scripts/audit_wire.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/audit_wire.py b/scripts/audit_wire.py index 219b016..25e7be9 100644 --- a/scripts/audit_wire.py +++ b/scripts/audit_wire.py @@ -161,7 +161,10 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) parser.add_argument("--strict", action="store_true", help="exit 1 on an unexplained drop") parser.add_argument("--fast", action="store_true", help="skip the slow research endpoints") - parser.add_argument("--verbose", action="store_true", help="list kept keys too") + parser.add_argument("--verbose", action="store_true", + help="show dropped keys with list indices preserved instead of " + "normalized to []; the normalized form is what matches " + "KNOWN_WIRE_EXTRAS") args = parser.parse_args() api_key = os.getenv("YDC_API_KEY") or os.getenv("YOU_API_KEY_AUTH") From fa5f68da5b2547144235114ac609575a2f246730 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 12:00:03 -0700 Subject: [PATCH 22/24] test: xfail the livecrawl=web live test, with the backend evidence recorded (DX-835) `livecrawl=web` combined with `livecrawl_formats=[markdown]` no longer returns `contents` on any web result. Verified against prod: livecrawl=web + formats=[markdown] 0/3 web results have contents <- fails livecrawl=all + formats=[markdown] 2/3 livecrawl=web (no formats) 2/3 extraction full_page markdown 3/3 extraction highlights 3/3 So it is one deprecated combination, and the replacement works fully. The test fails identically on origin/main in a clean worktree, so it predates this branch and is not a regression from the knowledge work. Marked xfail rather than relaxed or deleted: relaxing the assertion would bake in behavior nobody has said is intended, and deleting it would lose the record. The reason string carries the measured matrix so the next reader does not have to re-derive it. Non-strict, so it xpasses harmlessly if the server starts returning contents again -- at which point the marker should come off. Worth noting for whoever owns the search backend: MIGRATION.md still tells users "`livecrawl` and `livecrawl_formats` still work on `POST /v1/search`... removal is targeted for 4.0.0", and we are on 3.5.0. Deprecated is not the same as broken, so either the promise or the behavior needs to move. Live suite now 45 passed / 1 xfailed instead of 1 failed. --- tests/test_live.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_live.py b/tests/test_live.py index 9e850f8..1be5b40 100644 --- a/tests/test_live.py +++ b/tests/test_live.py @@ -124,6 +124,15 @@ def test_search_with_filters(self, you_client): assert len(res.results.web) <= 5 @pytest.mark.filterwarnings("ignore::DeprecationWarning") + @pytest.mark.xfail( + reason="Backend regression on a deprecated path: `livecrawl=web` combined " + "with `livecrawl_formats=[markdown]` no longer returns `contents` on any " + "web result (0/3), though `livecrawl=all`, `livecrawl=web` without " + "formats, and both `extraction` modes all still work (2/3, 2/3, 3/3, " + "3/3). Fails identically on main, so it predates this branch. Non-strict: " + "remove this marker if the server starts returning contents again. Note " + "MIGRATION.md still promises livecrawl works until 4.0.0.", + ) def test_search_with_livecrawl_web(self, you_client): """Test search with livecrawl for web results. From 3653f5a2e116c1263b597ded6e3526d3740e6945 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 12:22:22 -0700 Subject: [PATCH 23/24] fix: harden the wire audit against absent sections and extra="allow" models (DX-835) Two review findings, both reproduced before fixing. _audit indexed spy.last[root], so a response omitting that section raised KeyError and was reported as a call failure (exit 2), aborting the remaining endpoints. Reproduced with a body containing only `metadata`. Now uses .get() and reports the absent section explicitly rather than printing a vacuous "ok" -- "nothing to walk" and "walked it, kept everything" are different claims and the output should not conflate them. _walk only considered declared model_fields, so a model configured extra="allow" would report its retained keys as dropped. Not hypothetical: Result and TaskDetailInput are both configured that way. Extras present in model_extra now count as kept, and a real drop on an ordinary model is still reported. Also widened KNOWN_WIRE_EXTRAS to accept a None label meaning "any call". `results.web[].original_thumbnail_url` is undeclared by every published spec, and keying it to one call label made the same undeclared field look unexplained as soon as another call returned it. Verified: the absent-root simulation reports instead of raising; an extra="allow" model yields 0 dropped / 2 kept; an undeclared field on WebResult is still reported. Full audit over 7 endpoints and 241 wire keys exits 0. --- scripts/audit_wire.py | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/scripts/audit_wire.py b/scripts/audit_wire.py index 25e7be9..fcfa0e3 100644 --- a/scripts/audit_wire.py +++ b/scripts/audit_wire.py @@ -46,11 +46,14 @@ # so the list stays a decision rather than an accident. Remove an entry once the # spec declares the field and the model catches up. # -# Keyed by (call label, dotted path with list indices normalized to []). +# Keyed by (call label, dotted path with list indices normalized to []). A label +# of None means "any call" -- use that for a key no spec declares anywhere, so it +# stays suppressed when a different endpoint starts returning it too. KNOWN_WIRE_EXTRAS = { # Observed on web results; absent from web-search.json and every other - # published spec. Looks like a spec omission worth reporting upstream. - ("search:extraction-highlights", "results.web[].original_thumbnail_url"), + # published spec. Looks like a spec omission worth reporting upstream. Seen on + # both the extraction and knowledge calls, hence the wildcard label. + (None, "results.web[].original_thumbnail_url"), # Prod sends `warnings` at the top level of the finance-research response, and # the sibling research spec declares it (ResearchResponse models it), but # finance-research.json declares only `output`. Modeling it anyway would put @@ -104,8 +107,15 @@ def _walk(raw: Any, parsed: Any, path: str, dropped: list, kept: list) -> None: if parsed is None or not hasattr(type(parsed), "model_fields"): return keys = _json_keys(parsed) + # Models configured extra="allow" retain undeclared keys in model_extra, + # so those are kept, not dropped. Two models in the SDK are configured + # that way; without this they would report every extra key as lost. + extra = getattr(parsed, "model_extra", None) or {} for key, value in raw.items(): if key not in keys: + if key in extra: + kept.append(f"{path}.{key}") + continue dropped.append((path, key, sorted(keys))) continue kept.append(f"{path}.{key}") @@ -115,7 +125,12 @@ def _walk(raw: Any, parsed: Any, path: str, dropped: list, kept: list) -> None: _walk(raw_item, parsed_item, f"{path}[{index}]", dropped, kept) -def _audit(label: str, call, root: Optional[str], api_key: str) -> tuple[list, list]: +def _is_known(label: str, path: str) -> bool: + return (label, path) in KNOWN_WIRE_EXTRAS or (None, path) in KNOWN_WIRE_EXTRAS + + +def _audit(label: str, call, root: Optional[str], api_key: str) -> tuple[list, list, bool]: + """Return ``(dropped, kept, root_present)`` for one live call.""" spy = _Spy(httpx.HTTPTransport()) client = httpx.Client(transport=spy) dropped: list = [] @@ -125,12 +140,14 @@ def _audit(label: str, call, root: Optional[str], api_key: str) -> tuple[list, l parsed = call(you) if spy.last is None: raise RuntimeError(f"{label}: no JSON response captured") - raw = spy.last[root] if root else spy.last + # `.get`, not `[]`: a valid response may omit the section entirely, and + # that is a finding to report, not a reason to abort the whole audit. + raw = spy.last.get(root) if root else spy.last model = getattr(parsed, root, None) if root else parsed _walk(raw, model, root or "root", dropped, kept) finally: client.close() - return dropped, kept + return dropped, kept, raw is not None SEARCH = "what is the capital of France" @@ -177,13 +194,18 @@ def main() -> int: total_kept = 0 for label, call, root in _calls(args.fast): try: - dropped, kept = _audit(label, call, root, api_key) + dropped, kept, present = _audit(label, call, root, api_key) except Exception as exc: print(f" {label}: call failed ({type(exc).__name__}: {exc})") return 2 total_kept += len(kept) - known = [(p, k) for p, k, _ in dropped if (label, _normalize(f"{p}.{k}")) in KNOWN_WIRE_EXTRAS] - new = [d for d in dropped if (label, _normalize(f"{d[0]}.{d[1]}")) not in KNOWN_WIRE_EXTRAS] + known = [(p, k) for p, k, _ in dropped if _is_known(label, _normalize(f"{p}.{k}"))] + new = [d for d in dropped if not _is_known(label, _normalize(f"{d[0]}.{d[1]}"))] + if not present: + # Nothing to compare, which is itself worth saying out loud + # rather than reporting a vacuous "ok". + print(f" [{'no ' + root:>10}] {label}: response omitted `{root}`; nothing to walk") + continue status = "ok" if not new else f"{len(new)} DROPPED" print(f" [{status:>10}] {label}: kept {len(kept)} wire keys" + (f", {len(known)} known-undeclared" if known else "")) From f5d7d5eca0ddaa6c575345d8d3335a9422b5f465 Mon Sep 17 00:00:00 2001 From: Tyler Eastman Date: Tue, 22 Sep 2026 13:03:41 -0700 Subject: [PATCH 24/24] docs: make the README configuration snippets standalone; harden audit_wire --help (DX-835) Two findings from the fresh review pass. README's five `## Configuration` snippets each constructed their own client with `api_key_auth=key`, but no block defined `key` or imported `You`, so copy-pasting any of them raised NameError. They read as standalone, unlike the Quick Start continuations elsewhere in the file, which reuse an established `you` and are obviously fragments -- that is what makes these worth fixing rather than leaving as illustrative. Each now carries its imports and `os.getenv("YDC_API_KEY")`. One of the five (Retries) was already touched by this branch when it gained `timeout_ms`, so leaving it half-fixed was the worst of the available options. scripts/audit_wire.py built its argparse description from `__doc__.splitlines()[1]`, which raises AttributeError under `python -OO` and is brittle to any reflow of the module docstring. Reproduced: `python -OO scripts/audit_wire.py --help` failed with `'NoneType' object has no attribute 'splitlines'`. Replaced with a literal string, matching how the sibling scripts/check_drift.py already does it -- that removes both failure modes at once rather than guarding one, and keeps the two scripts consistent. Verified: --help works under -OO and normal mode; no `api_key_auth=key` remains in README; all 21 README python blocks parse; the runnable/timeout_ms checker drops from 8 self-constructed-client violations to 7, the remainder being Quick Start continuations that never construct a client. Offline gate 450, mypy clean over 87 files, pylint 10.00/10, drift --strict clean, docs audit 0 findings, wire audit 221 keys with no unexplained drops. --- README.md | 30 ++++++++++++++++++++++++------ scripts/audit_wire.py | 4 +++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ab9c550..bb7b2ab 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,9 @@ failures such as connection resets and timeouts. The SDK does **not** retry by default. Opt in per call or for the whole client: ```python +import os + +from youdotcom import You from youdotcom.utils import BackoffStrategy, RetryConfig retries = RetryConfig( @@ -379,7 +382,7 @@ retries = RetryConfig( retry_connection_errors=True, ) -with You(api_key_auth=key, retry_config=retries, timeout_ms=60_000) as you: # whole client +with You(api_key_auth=os.getenv("YDC_API_KEY"), retry_config=retries, timeout_ms=60_000) as you: # whole client res = you.search(query="...", retries=retries) # or one call ``` @@ -395,7 +398,11 @@ without a timeout will raise `httpx.ReadTimeout` before the API responds. `timeout_ms` applies to the whole client or to a single call: ```python -with You(api_key_auth=key, timeout_ms=60_000) as you: +import os + +from youdotcom import You + +with You(api_key_auth=os.getenv("YDC_API_KEY"), timeout_ms=60_000) as you: answer = you.answer(query="...") # inherits 60s results = you.search(query="...", timeout_ms=10_000) # this call only ``` @@ -472,11 +479,15 @@ Pass any `httpx.Client` / `httpx.AsyncClient` to control proxies, TLS, custom headers, or connection limits: ```python +import os + import httpx +from youdotcom import You + http_client = httpx.Client(proxy="http://localhost:8030", headers={"x-team": "search"}) -with You(api_key_auth=key, client=http_client) as you: +with You(api_key_auth=os.getenv("YDC_API_KEY"), client=http_client) as you: ... http_client.close() # a transport you supply is yours to close @@ -493,9 +504,13 @@ creates. manager. Both transports are released on exit. ```python -with You(api_key_auth=key) as you: +import os + +from youdotcom import You + +with You(api_key_auth=os.getenv("YDC_API_KEY")) as you: ... -# or: async with You(api_key_auth=key) as you: +# or: async with You(api_key_auth=os.getenv("YDC_API_KEY")) as you: ``` An instance is not reusable after the block exits, including for calls of the @@ -507,8 +522,11 @@ Set `YOU_DEBUG=1` for request and response logging, or pass your own logger: ```python import logging +import os + +from youdotcom import You -with You(api_key_auth=key, debug_logger=logging.getLogger("youdotcom")) as you: +with You(api_key_auth=os.getenv("YDC_API_KEY"), debug_logger=logging.getLogger("youdotcom")) as you: ... ``` diff --git a/scripts/audit_wire.py b/scripts/audit_wire.py index fcfa0e3..27c655d 100644 --- a/scripts/audit_wire.py +++ b/scripts/audit_wire.py @@ -175,7 +175,9 @@ def _calls(fast: bool) -> list: def main() -> int: - parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser = argparse.ArgumentParser( + description="Audit what the live API returns against what the SDK models keep." + ) parser.add_argument("--strict", action="store_true", help="exit 1 on an unexplained drop") parser.add_argument("--fast", action="store_true", help="skip the slow research endpoints") parser.add_argument("--verbose", action="store_true",