diff --git a/docs/02_concepts/12_typed_models.mdx b/docs/02_concepts/12_typed_models.mdx index 58df78ec..1b75b205 100644 --- a/docs/02_concepts/12_typed_models.mdx +++ b/docs/02_concepts/12_typed_models.mdx @@ -36,6 +36,50 @@ Every method that returns a structured payload returns a Pydantic model. Fields Date strings are automatically parsed into timezone-aware `datetime.datetime` objects, enums into `Literal` aliases, and nested objects into their own typed models, so you can compose attribute access without manual conversion. +## URL fields + +Fields holding a URL, such as `Run.container_url` or `Dataset.console_url`, are typed as [`AnyUrl`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.AnyUrl). Their values are validated and normalized, so the string you read back can differ from the one the API sent. Both forms denote the same URL under [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.3), they're just different strings. + +```python +from pydantic import AnyUrl + +# An empty path becomes '/'. +str(AnyUrl('https://abc123.runs.apify.net')) # 'https://abc123.runs.apify.net/' + +# The host is lowercased. +str(AnyUrl('https://EXAMPLE.com/Path')) # 'https://example.com/Path' + +# A default port is dropped. +str(AnyUrl('https://example.com:443/path')) # 'https://example.com/path' + +# An internationalized host is punycoded. +str(AnyUrl('https://www.žluty.cz')) # 'https://www.xn--luty-kbb.cz/' + +# Unsafe characters are percent-encoded. +str(AnyUrl('https://example.com/a b')) # 'https://example.com/a%20b' +``` + +Because of the normalization, compare URLs in their normalized form instead of as raw strings. `AnyUrl` compares the parsed URL, not the input text. + +```python +run = client.run('my-run-id').get() +stored_url = 'https://abc123.runs.apify.net' + +# Wrong, compares a raw string with a normalized one. +stored_url == str(run.container_url) # False + +# Right, both sides are normalized before the comparison. +AnyUrl(stored_url) == run.container_url # True +``` + +To build a longer URL out of a URL field, use [`urljoin`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urljoin) rather than string concatenation. It gives the same result whether or not the base ends with a slash. + +```python +from urllib.parse import urljoin + +urljoin(str(run.container_url), 'status') # 'https://abc123.runs.apify.net/status' +``` + ## Providing structured input A Pydantic model returned from one client call can be passed directly into any other method that accepts the same shape — useful for round-trip flows where you read a resource, tweak it, and write it back. diff --git a/docs/04_upgrading/upgrading_to_v3.mdx b/docs/04_upgrading/upgrading_to_v3.mdx index 7572c19a..1483021e 100644 --- a/docs/04_upgrading/upgrading_to_v3.mdx +++ b/docs/04_upgrading/upgrading_to_v3.mdx @@ -30,6 +30,27 @@ Two endpoints still return plain types because their payloads are user-defined: On the input side, methods now also accept Pydantic models in addition to dicts. Plain dicts continue to work — keys may use either snake_case or camelCase, and each input shape has a matching [`TypedDict`](https://docs.python.org/3/library/typing.html#typing.TypedDict) so editors and type checkers can validate the keys. See [Typed models](/api/client/python/docs/concepts/typed-models) for details. +## URL fields are normalized + +URL fields on the returned models are typed as [`AnyUrl`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.AnyUrl), which normalizes the value during validation. In v2 the client handed back the raw string from the API. In v3 the string can differ, most visibly by an added trailing slash. Normalization also lowercases the host, drops default ports, punycodes internationalized hosts, and percent-encodes unsafe characters. + +Code that compares a stored URL with a URL field has to compare normalized values. + +```python +from pydantic import AnyUrl + +run = client.run('my-run-id').get() +stored_url = 'https://abc123.runs.apify.net' + +# Before, the raw string matched the API response. +stored_url == str(run.container_url) # False in v3 + +# After, normalize both sides before comparing. +AnyUrl(stored_url) == run.container_url # True +``` + +The affected fields are `container_url`, `console_url`, `standby_url`, `request_url`, `url`, `picture_url`, `user_picture_url`, `website_url`, and the `*_public_url` fields on storage models. To build longer URLs out of them, use [`urljoin`](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urljoin) instead of string concatenation. For details, see [Typed models](/api/client/python/docs/concepts/typed-models#url-fields). + ## Tiered timeouts The single global timeout has been replaced by four tiers — `short` (5 s), `medium` (30 s), `long` (360 s), and `no_timeout`. Each method picks an appropriate default. Override per call, or change tier defaults on the constructor: