Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ All notable changes to the You.com Python SDK will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [3.1.2] - 2026-08-19

Sister release track to the extraction-parameter rollout
(3.1.0 + 3.1.1). No breaking changes. All changes are
backward-compatible; everything that worked on 3.1.1 keeps working
unchanged.

The top-level `from youdotcom import *` surface is narrowed to the
documented public API (`You`, `VERSION`, `OPENAPI_DOC_VERSION`,
`USER_AGENT`). Previously re-exported names like `SDKConfiguration`
remain available via their submodules (e.g.,
`from youdotcom.sdkconfiguration import SDKConfiguration`).
Comment on lines +15 to +19

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Align CHANGELOG import * claim with actual exports

The CHANGELOG says from youdotcom import * is narrowed to (You, VERSION, OPENAPI_DOC_VERSION, USER_AGENT), but src/youdotcom/__init__.py's __all__ also exports version metadata dunders like __version__, __user_agent__, __openapi_doc_version__, and __title__. Either update the CHANGELOG wording to mention the dunder exports, or trim __all__ so the release notes match the actual wildcard-import surface.

Suggested change
The top-level `from youdotcom import *` surface is narrowed to the
documented public API (`You`, `VERSION`, `OPENAPI_DOC_VERSION`,
`USER_AGENT`). Previously re-exported names like `SDKConfiguration`
remain available via their submodules (e.g.,
`from youdotcom.sdkconfiguration import SDKConfiguration`).
The top-level `from youdotcom import *` surface is narrowed to the
documented public API (`You`, `VERSION`, `OPENAPI_DOC_VERSION`,
`USER_AGENT`). Version metadata dunders (e.g. `__version__`) remain
available at the package root. Previously re-exported names like
`SDKConfiguration` remain available via their submodules (e.g.,
`from youdotcom.sdkconfiguration import SDKConfiguration`).


### Fixed

- **Models are usable inside a Temporal Workflow.**
`youdotcom/__init__.py` no longer eagerly pulls 306 modules
(including `httpx` and `urllib.request`), so a Workflow module that
does `from youdotcom.models import SearchResponse` (no
`workflow.unsafe.imports_passed_through()` work-around) prepares
cleanly under the default `SandboxedWorkflowRunner`. PEP 562 module
`__getattr__` mirrors the public-import surface without dragging
transport; the `models/__init__.py` lazy pattern shipped in 3.0.0 was
lifted to the package root. Regression coverage lives in
`tests/test_root_init.py` (subprocess assertion: `import youdotcom`
does not load `httpx` / `urllib.request`).
- **`ResearchTaskStreamEvent.event` accepts future SSE event names.**
The discriminator field widens from `Event` to
`Union[Event, UnrecognizedStr]`, so a server-side event-name addition
(a new terminal status, a retry signal, anything the SDK does not
yet enumerate) does not raise `ResponseValidationError` on the
unmarshal path. Known event names still resolve to the `Event` enum
member; unknown names unwrap as a `str` subclass that compares equal
to its raw value, so callers branching on raw strings
(`evt.event == "completed"`) keep working unchanged.
Exhaustive-enumeration callers (`isinstance(evt.event, Event)`) get
the right negative answer. Coverage in
`tests/test_researchtaskstreamevent.py`.

### Added

- **Attribution header `X-Client-Info` on every outbound request.**
New optional `You(app_title=..., app_url=...)` constructor
args populate the `title=` and `url=` segments. Wire format:

python-sdk; client=youdotcom/<version>; title=<title>; url=<url>; ua=python/<V> httpx/<V>

so the analytics layer can distinguish SDK traffic from other
sources.
- **`safesearch` parameter on `You.answer()`.**
The Answer API now supports the same explicit-content filtering
as the Web Search API. New optional `safesearch` kwarg on
`answer()` and `answer_async()` accepts ``off``, ``moderate``
(default), or ``strict``. Case-insensitive, like the search
counterpart. Existing call sites are unaffected.

## [3.1.1] - 2026-08-12

### Fixed
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ A synthesized answer with citations, grounded in live web results.
res = you.answer(
query="What are the tradeoffs of vector vs. keyword search?",
freshness="month",
safesearch="strict",
include_domains=["arxiv.org"],
)

Expand Down Expand Up @@ -356,6 +357,33 @@ is the exception: the helpers under
[Long-running research](#long-running-research) manage their own deadlines, so
`timeout_s` there bounds the wait rather than `timeout_ms`.

### Attribution

Every SDK request emits an `X-Client-Info` header so the analytics layer can
split SDK traffic from MCP traffic. The wire format is:

```
python-sdk; client=youdotcom/<version>[; title=<title>][; url=<url>]; ua=python/<V> httpx/<V>
```

Optionally pass `app_title` and `app_url` to populate the `title=` and
`url=` segments:

```python
import os
from youdotcom import You

with You(
api_key_auth=os.getenv("YDC_API_KEY"),
app_title="MyAgent",
app_url="https://example.com",
timeout_ms=60_000,
) as you:
res = you.search(query="...")
```

Both arguments are optional; existing call sites are unaffected.

### Servers

`search` and `contents` go to `https://ydc-index.io`. Everything else goes to
Expand Down
33 changes: 32 additions & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,35 @@ The `extraction` parameter replaces the deprecated `livecrawl` /
Unknown keys inside `extraction` raise `ValidationError` locally, and passing
`extraction` together with `livecrawl` / `livecrawl_formats` raises
`ValueError` — both mirror the server's 422 contract so callers fail-fast.
<!-- End SDK Example Usage [extraction] -->
<!-- End SDK Example Usage [extraction] -->

<!-- Start SDK Example Usage [attribution] -->
```python
# Tag every outbound request with a caller-identity header so the
# analytics layer can split SDK traffic from MCP traffic.
import os
from youdotcom import You


with You(
api_key_auth=os.getenv("YDC_API_KEY"),
app_title="MyAgent",
app_url="https://example.com",
timeout_ms=60_000,
) as you:

res = you.search(query="What did OpenAI announce this week?")

# Handle response
print(res)
```

`X-Client-Info` sent on the wire:

```
python-sdk; client=youdotcom/<version>; title=MyAgent; url=https://example.com; ua=python/<V> httpx/<V>
```

`app_title` and `app_url` are optional. When omitted, those segments are
dropped entirely.
<!-- End SDK Example Usage [attribution] -->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Add a trailing newline to USAGE.md

USAGE.md still ends without a final newline (shown in the diff as \\ No newline at end of file), which creates noisy diffs and can trip some tooling; add a newline at EOF.

Suggested change
<!-- End SDK Example Usage [attribution] -->
<!-- End SDK Example Usage [attribution] -->

1 change: 1 addition & 0 deletions docs/models/answerrequestbody.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Request body for `POST /v1/answer`.
| `freshness` | [Optional[models.FreshnessValue]](../models/freshnessvalue.md) | :heavy_minus_sign: | Specifies the freshness of the results. One of `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD`. |
| `country` | [Optional[models.Country]](../models/country.md) | :heavy_minus_sign: | A supported country code that determines the geographical focus of the web results. |
| `language` | [Optional[models.Language]](../models/language.md) | :heavy_minus_sign: | A supported BCP 47 language tag that determines the language of the web results. |
| `safesearch` | [Optional[models.SafeSearch]](../models/safesearch.md) | :heavy_minus_sign: | Configures the safesearch filter for content moderation. `off`, `moderate` (default), or `strict`. |
| `include_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to exclusively include. Cannot combine with `exclude_domains` or `boost_domains`. Max 500. |
| `exclude_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to exclude. Cannot combine with `include_domains`. Can combine with `boost_domains`. Max 500. |
| `boost_domains` | Optional[List[*str*]] | :heavy_minus_sign: | Domains to prefer in ranking. Cannot combine with `include_domains`. Can combine with `exclude_domains`. Max 500. |
13 changes: 13 additions & 0 deletions docs/sdks/you/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ with You(
print(res)
```

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` | *str* | :heavy_check_mark: | The search query. Max 400 characters. |
| `freshness` | *str* | :heavy_minus_sign: | `day`, `week`, `month`, `year`, or `YYYY-MM-DDtoYYYY-MM-DD`. |
| `country` | *str* | :heavy_minus_sign: | Country code (e.g. `US`, `GB`). Case-insensitive. |
| `language` | *str* | :heavy_minus_sign: | BCP 47 language tag (e.g. `EN`, `FR`). Case-insensitive. |
| `safesearch` | *str* | :heavy_minus_sign: | Explicit-content filtering: `off`, `moderate` (default), or `strict`. Case-insensitive. |
| `include_domains` | List[*str*] | :heavy_minus_sign: | Only return results from these domains. Max 500. |
| `exclude_domains` | List[*str*] | :heavy_minus_sign: | Exclude results from these domains. Max 500. |
| `boost_domains` | List[*str*] | :heavy_minus_sign: | Prefer results from these domains. Max 500. |

## search

Search via `POST /v1/search`. Returns unified search results from web and news sources. Requires an API key. Country and language accept plain strings and are normalized to uppercase.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "youdotcom"
version = "3.1.1"
version = "3.1.2"
description = "The official You.com Python SDK."
authors = [{ name = "You.com" },]
readme = "README.md"
Expand Down
96 changes: 90 additions & 6 deletions src/youdotcom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,99 @@
"""Public surface for ``youdotcom``.

Imports are resolved lazily via PEP 562 module ``__getattr__`` so that
``import youdotcom`` does **not** pull transport-layer modules
(``httpx``, ``urllib.request``) into ``sys.modules``. This matters for
Temporal Workflow sandboxes, which reject transport imports at Worker
construction time and cannot be patched around with
``workflow.unsafe.imports_passed_through()`` because the parent package
import runs before any submodule body.

Public surface (trying out ``from youdotcom import <name>``):

- ``You`` — the unified API client (from ``.sdk``)
- ``VERSION`` / ``OPENAPI_DOC_VERSION`` / ``USER_AGENT`` — version pins
populated from ``_version.py`` at module load

Sub-packages accessed as ``youdotcom.<name>.X``:

- ``models``, ``errors``, ``utils``, ``types``, ``_hooks``, ``_shims``

Lazy-init port. Mirrors the pattern used in
``youdotcom.models.__init__`` (shipped in 3.0.0) at the SDK root.
"""

from typing import Any, TYPE_CHECKING

from youdotcom.utils.dynamic_imports import lazy_getattr, lazy_dir

from ._version import (
__title__,
__version__,
__openapi_doc_version__,
__title__,
__user_agent__,
__version__,
)
from .sdk import *
from .sdkconfiguration import *

if TYPE_CHECKING:
from .sdk import You


__all__ = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Reconcile the root import surface with the "no breaking changes" claim

Previously youdotcom/__init__.py re-exported a broad surface via from .sdk import * and from .sdkconfiguration import *, so imports like from youdotcom import SDKConfiguration (and other non-underscore names from those modules) would resolve; this change restricts the root surface to You plus version constants only, which is a backward-incompatible change for any callers relying on those root re-exports. If this release is meant to be non-breaking, consider adding the key historical symbols to _dynamic_imports (lazy per-name mapping) or explicitly documenting/deprecating the removed root imports in release notes.

"OPENAPI_DOC_VERSION",
"USER_AGENT",
"VERSION",
"You",
"__openapi_doc_version__",
"__title__",
"__user_agent__",
"__version__",
]


# Explicit module-level constants. These are cheap strings resolved
# eagerly from ``_version.py``, which doesn't pull transport-layer
# modules. Keeping them as real attributes (vs. routing through
# ``__getattr__``) preserves `from youdotcom import VERSION` ergonomics
# and avoids the overhead of an indirection on a one-line lookup.
VERSION: str = __version__
OPENAPI_DOC_VERSION = __openapi_doc_version__
USER_AGENT = __user_agent__
OPENAPI_DOC_VERSION: str = __openapi_doc_version__
USER_AGENT: str = __user_agent__


# Lazy mapping for the single non-constant public attribute, ``You``.
_dynamic_imports: dict[str, str] = {
"You": ".sdk",
}


# Sub-packages accessible as ``youdotcom.<name>`` (PEP 562 routes the
# attribute lookup through ``__getattr__`` so the submodule is imported
# on demand, the first time someone touches it).
_sub_packages: list[str] = [
"_hooks",
"_shims",
"errors",
"models",
"types",
"utils",
]


def __getattr__(attr_name: str) -> Any:
return lazy_getattr(
attr_name,
package=__package__,
dynamic_imports=_dynamic_imports,
sub_packages=_sub_packages,
)


def __dir__():
return sorted(
set(
lazy_dir(
dynamic_imports=_dynamic_imports,
sub_packages=_sub_packages,
)
)
| set(__all__)
)
2 changes: 1 addition & 1 deletion src/youdotcom/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import importlib.metadata

__title__: str = "youdotcom"
__version__: str = "3.1.0"
__version__: str = "3.1.2"
__openapi_doc_version__: str = "1.0.0"

try:
Expand Down
9 changes: 9 additions & 0 deletions src/youdotcom/basesdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from youdotcom.utils import (
RetryConfig,
SerializedRequestBody,
build_client_info_header,
get_body_content,
run_sync_in_thread,
)
Expand Down Expand Up @@ -199,6 +200,14 @@ def _build_request_with_client(
headers = utils.get_headers(request, _globals)
headers["Accept"] = accept_header_value
headers[user_agent_header] = self.sdk_configuration.user_agent
# ``X-Client-Info`` attribution header, set at the same single
# site as ``User-Agent`` — every endpoint funnels through
# ``_build_request_with_client``, so a single construction
# point prevents per-endpoint drift.
headers["X-Client-Info"] = build_client_info_header(
app_title=self.sdk_configuration.app_title,
app_url=self.sdk_configuration.app_url,
)

if security is not None:
if callable(security):
Expand Down
6 changes: 5 additions & 1 deletion src/youdotcom/models/answerrequestbody.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .country import Country
from .freshnessvalue import FreshnessValue
from .language import Language
from .safesearch import SafeSearch
from pydantic import model_serializer
from typing import List, Optional
from youdotcom.types import BaseModel, UNSET_SENTINEL
Expand All @@ -22,6 +23,9 @@ class AnswerRequestBody(BaseModel):
language: Optional[Language] = None
r"""A supported BCP 47 language tag that determines the language of the web results."""

safesearch: Optional[SafeSearch] = None
r"""Configures the safesearch filter for content moderation. ``off``, ``moderate`` (default), or ``strict``."""

include_domains: Optional[List[str]] = None
r"""Domains to exclusively include. Cannot combine with ``exclude_domains`` or ``boost_domains``. Max 500."""

Expand All @@ -34,7 +38,7 @@ class AnswerRequestBody(BaseModel):
@model_serializer(mode="wrap")
def serialize_model(self, handler):
optional_fields = set(
["freshness", "country", "language", "include_domains", "exclude_domains", "boost_domains"]
["freshness", "country", "language", "safesearch", "include_domains", "exclude_domains", "boost_domains"]
)
serialized = handler(self)
m = {}
Expand Down
Loading
Loading