Source code for pyfetcher.contracts.url
"""Validated URL value objects for :mod:`pyfetcher`.
Purpose:
Provide a small immutable wrapper around :class:`pydantic.HttpUrl` with
useful derived helpers for host, path, and query decomposition.
Design:
- ``URL`` is intentionally pure and contains no I/O behavior.
- Computed properties remain deterministic and serialization-friendly.
- The model is frozen so it behaves like a value object.
Examples:
::
>>> url = URL("https://example.com/a/b/?x=1&x=2&y=")
>>> url.host
'example.com'
>>> url.path_segments
['a', 'b']
>>> url.query_params["x"]
['1', '2']
"""
from __future__ import annotations
from urllib.parse import parse_qs
from pydantic import ConfigDict, HttpUrl, RootModel, computed_field
[docs]
class URL(RootModel[HttpUrl]):
"""Validated HTTP/HTTPS URL with derived helpers.
Wraps :class:`pydantic.HttpUrl` to provide computed decomposition of
scheme, host, port, path segments, and query parameters as a frozen
value object suitable for embedding in request models.
Args:
root: The raw URL string or ``HttpUrl`` instance to validate.
Raises:
pydantic.ValidationError: If the value is not a valid HTTP/HTTPS URL.
Examples:
::
>>> url = URL("https://example.com:8443/a/b/?x=1&x=2")
>>> url.host
'example.com'
>>> url.port
8443
"""
model_config = ConfigDict(frozen=True)
@computed_field
@property
def scheme(self) -> str:
"""Return the URL scheme (e.g. ``'https'``).
Returns:
The scheme component of the URL.
Examples:
::
>>> URL("https://example.com").scheme
'https'
"""
return self.root.scheme
@computed_field
@property
def host(self) -> str | None:
"""Return the hostname.
Returns:
The host if present, otherwise ``None``.
Examples:
::
>>> URL("https://example.com").host
'example.com'
"""
return self.root.host
@computed_field
@property
def port(self) -> int | None:
"""Return the explicit port number.
Returns:
The explicit port if present, otherwise ``None``.
Examples:
::
>>> URL("https://example.com:9443").port
9443
"""
return self.root.port
@computed_field
@property
def path(self) -> str | None:
"""Return the path component.
Returns:
The path if present, otherwise ``None``.
Examples:
::
>>> URL("https://example.com/a/b").path
'/a/b'
"""
return self.root.path
@computed_field
@property
def path_segments(self) -> list[str]:
"""Return non-empty path segments.
Returns:
A list of non-empty path segments split on ``/``.
Examples:
::
>>> URL("https://example.com/a/b/").path_segments
['a', 'b']
"""
return [segment for segment in (self.root.path or "").split("/") if segment]
@computed_field
@property
def query(self) -> str | None:
"""Return the raw query string.
Returns:
The raw query string if present, otherwise ``None``.
Examples:
::
>>> URL("https://example.com?a=1&b=2").query
'a=1&b=2'
"""
return self.root.query
@computed_field
@property
def query_params(self) -> dict[str, list[str]]:
"""Return parsed query parameters.
Returns:
Parsed query parameters as a dict mapping keys to lists of values,
preserving blank values.
Examples:
::
>>> URL("https://example.com?a=1&a=2&b=").query_params
{'a': ['1', '2'], 'b': ['']}
"""
return parse_qs(self.root.query or "", keep_blank_values=True)
[docs]
def unicode_string(self) -> str:
"""Return the normalized URL string.
Returns:
The normalized URL as a Unicode string.
Examples:
::
>>> URL("https://example.com").unicode_string()
'https://example.com/'
"""
return str(self.root)
def __str__(self) -> str:
"""Return the normalized URL string.
Returns:
The normalized URL as a Unicode string.
Examples:
::
>>> str(URL("https://example.com"))
'https://example.com/'
"""
return str(self.root)