diff --git a/src/apify_client/_resource_clients/_resource_client.py b/src/apify_client/_resource_clients/_resource_client.py index bb5d85fd..4be84764 100644 --- a/src/apify_client/_resource_clients/_resource_client.py +++ b/src/apify_client/_resource_clients/_resource_client.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from apify_client._client_registry import ClientRegistry, ClientRegistryAsync from apify_client.http_clients import HttpClient, HttpClientAsync - from apify_client.types import Timeout + from apify_client.types import QueryParams, QueryParamValue, Timeout _TerminalActorJobStatus = Literal['SUCCEEDED', 'FAILED', 'TIMED-OUT', 'ABORTED'] """Subset of `ActorJobStatus` values that indicate the job has finished and will not change again.""" @@ -41,7 +41,7 @@ def __init__( resource_path: str, client_registry: Any, resource_id: str | None = None, - params: dict | None = None, + params: QueryParams | None = None, api_base_url: str | None = None, ) -> None: """Initialize the resource client. @@ -63,7 +63,7 @@ def __init__( self._api_base_url = api_base_url or base_url self._public_base_url = public_base_url self._http_client = http_client - self._default_params = params or {} + self._default_params = dict(params) if params else {} self._resource_path = resource_path self._resource_id = resource_id self._client_registry = client_registry @@ -120,7 +120,7 @@ def _build_url( return url - def _build_public_url(self, path: str, params: dict[str, Any]) -> str: + def _build_public_url(self, path: str, params: QueryParams) -> str: """Build a public resource URL with API-normalized query params. Normalizes `params` the same way the HTTP request path does (bool -> true/false, list -> comma-joined, @@ -139,7 +139,7 @@ def _build_public_url(self, path: str, params: dict[str, Any]) -> str: public_url = public_url._replace(query=urlencode(filtered_params)) return urlunparse(public_url) - def _build_params(self, **kwargs: Any) -> dict: + def _build_params(self, **kwargs: QueryParamValue) -> dict[str, QueryParamValue]: """Merge default params with method params, filtering out None values. Args: @@ -200,7 +200,7 @@ def __init__( resource_path: str, client_registry: ClientRegistry, resource_id: str | None = None, - params: dict | None = None, + params: QueryParams | None = None, api_base_url: str | None = None, ) -> None: """Initialize the resource client. @@ -313,7 +313,7 @@ def _wait_for_finish( self, *, url: str, - params: dict, + params: QueryParams, timeout: Timeout, wait_duration: timedelta | None = None, ) -> dict | None: @@ -395,7 +395,7 @@ def __init__( resource_path: str, client_registry: ClientRegistryAsync, resource_id: str | None = None, - params: dict | None = None, + params: QueryParams | None = None, api_base_url: str | None = None, ) -> None: """Initialize the resource client. @@ -508,7 +508,7 @@ async def _wait_for_finish( self, *, url: str, - params: dict, + params: QueryParams, timeout: Timeout, wait_duration: timedelta | None = None, ) -> dict | None: diff --git a/src/apify_client/_resource_clients/request_queue.py b/src/apify_client/_resource_clients/request_queue.py index 0540bb35..baa1bf30 100644 --- a/src/apify_client/_resource_clients/request_queue.py +++ b/src/apify_client/_resource_clients/request_queue.py @@ -56,7 +56,7 @@ RequestWithoutIdCamelDict, RequestWithoutIdDict, ) - from apify_client.types import Timeout + from apify_client.types import QueryParams, Timeout _RQ_MAX_REQUESTS_PER_BATCH = 25 """Maximum number of requests the API accepts in a single batch call.""" @@ -946,7 +946,7 @@ async def _batch_add_requests_worker( self, *, queue: asyncio.Queue[Iterable[bytes]], - request_params: dict, + request_params: QueryParams, timeout: Timeout, ) -> BatchAddResponse: """Worker function to process a batch of requests. diff --git a/src/apify_client/http_clients/_base.py b/src/apify_client/http_clients/_base.py index b06d542c..cad2efdc 100644 --- a/src/apify_client/http_clients/_base.py +++ b/src/apify_client/http_clients/_base.py @@ -43,7 +43,7 @@ from typing import Self from apify_client.http_compressors._base import HttpCompressor - from apify_client.types import JsonSerializable, StreamedBodySource, Timeout + from apify_client.types import JsonSerializable, QueryParams, StreamedBodySource, Timeout logger = logging.getLogger(logger_name) logger_once = LoggerOnce(logger) @@ -227,7 +227,7 @@ def _get_header(headers: dict[str, str], name: str) -> str | None: return next((value for key, value in headers.items() if key.lower() == name.lower()), None) @staticmethod - def _parse_params(params: dict[str, Any] | None) -> dict[str, Any] | None: + def _parse_params(params: QueryParams | None) -> dict[str, Any] | None: """Convert request parameters to Apify API-compatible formats. Converts booleans to 'false'/'true', lists to comma-separated strings, datetimes to ISO 8601 Zulu format. @@ -309,7 +309,7 @@ def _prepare_request_call( self, *, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: JsonSerializable | None = None, ) -> tuple[dict[str, str], dict[str, Any] | None, bytes | StreamedRequestBody | None]: @@ -504,7 +504,7 @@ def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: JsonSerializable | None = None, stream: bool | None = None, @@ -516,7 +516,8 @@ def call( method: HTTP method (GET, POST, PUT, DELETE, etc.). url: Full URL to make the request to. headers: Additional headers to include. - params: Query parameters to append to the URL. + params: Query parameters to append to the URL. See `QueryParamValue` for how each value is converted + before it is sent. data: Raw request body. An `io.IOBase` stream such as an open file, an iterable of byte chunks, or a streamed `HttpResponse` is sent in chunks as it is read, and any other object with a callable `read` is read whole, see `StreamedRequestBody`. Streaming is experimental, and its behavior may change in @@ -715,7 +716,7 @@ async def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: JsonSerializable | None = None, stream: bool | None = None, @@ -727,7 +728,8 @@ async def call( method: HTTP method (GET, POST, PUT, DELETE, etc.). url: Full URL to make the request to. headers: Additional headers to include. - params: Query parameters to append to the URL. + params: Query parameters to append to the URL. See `QueryParamValue` for how each value is converted + before it is sent. data: Raw request body. An `io.IOBase` stream such as an open file, an iterable of byte chunks, or a streamed `HttpResponse` is sent in chunks as it is read, and any other object with a callable `read` is read whole, see `StreamedRequestBody`. Streaming is experimental, and its behavior may change in diff --git a/src/apify_client/types.py b/src/apify_client/types.py index f7a8d555..d7eb30d3 100644 --- a/src/apify_client/types.py +++ b/src/apify_client/types.py @@ -1,7 +1,8 @@ from __future__ import annotations from collections.abc import AsyncIterable, Iterable, Mapping, Sequence -from datetime import timedelta +from datetime import datetime, timedelta +from decimal import Decimal from typing import IO, TYPE_CHECKING, Literal, Protocol from apify_client._models import CreateWebhookRequest, WebhookRepresentation @@ -19,6 +20,18 @@ HttpCompressionAlgorithm = Literal['brotli', 'gzip'] """Accepted string literals for the `compression` parameter on `ApifyClient` and `ApifyClientAsync`.""" +QueryParamValue = str | int | float | bool | Decimal | datetime | list[str] | None +"""Type of one query parameter value accepted by the client. + +The HTTP client converts a value before sending it: a `bool` becomes `true` or `false`, a `list[str]` becomes one +comma-separated value, a `datetime` becomes an ISO 8601 string in UTC (a naive one is treated as UTC), and a `None` +value is left out. Any other value is sent as its `str()`. Only a `list` is joined, so pass a `list`, not a tuple +or another sequence. +""" + +QueryParams = Mapping[str, QueryParamValue] +"""Type for the `params` parameter on `HttpClient.call` and `HttpClientAsync.call`: query parameters of the request.""" + Timeout = timedelta | Literal['no_timeout', 'short', 'medium', 'long'] """Type for the `timeout` parameter on resource client methods. @@ -91,6 +104,8 @@ def read(self) -> bytes | str | Awaitable[bytes | str]: __all__ = [ 'HttpCompressionAlgorithm', 'JsonSerializable', + 'QueryParamValue', + 'QueryParams', 'StreamedBodySource', 'SupportsRead', 'Timeout', diff --git a/tests/unit/test_pluggable_http_client.py b/tests/unit/test_pluggable_http_client.py index edbc6c0a..2034b832 100644 --- a/tests/unit/test_pluggable_http_client.py +++ b/tests/unit/test_pluggable_http_client.py @@ -35,7 +35,7 @@ from pytest_httpserver import HTTPServer - from apify_client.types import StreamedBodySource, Timeout + from apify_client.types import QueryParams, StreamedBodySource, Timeout @dataclass @@ -94,7 +94,7 @@ def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: Any = None, stream: bool | None = None, @@ -128,7 +128,7 @@ async def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: Any = None, stream: bool | None = None, @@ -550,7 +550,7 @@ def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: Any = None, **_kwargs: Any, @@ -575,7 +575,7 @@ async def call( method: str, url: str, headers: dict[str, str] | None = None, - params: dict[str, Any] | None = None, + params: QueryParams | None = None, data: str | bytes | bytearray | StreamedBodySource | None = None, json: Any = None, **_kwargs: Any,