Skip to content

Commit 3f6373d

Browse files
committed
feat: Refactor the implementation to use hierarchy of classes
1 parent cdcbac3 commit 3f6373d

12 files changed

Lines changed: 260 additions & 198 deletions

File tree

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,21 +48,28 @@
4848

4949
or any other Python package manager that consumes PyPI.
5050

51-
To enable brotli request-body compression (better than gzip, especially for large payloads), install the optional extra:
51+
The client compresses request bodies with gzip by default (no extra dependencies required). To opt in to brotli
52+
(better compression ratio), install the optional extra and pass `encoding='brotli'`:
5253

5354
```bash
5455
pip install "apify-client[brotli]"
5556
# or
5657
uv add "apify-client[brotli]"
5758
```
5859

59-
Without this extra the client falls back to gzip automatically. You can also force gzip at runtime without uninstalling the extra:
60-
6160
```python
62-
client = ApifyClient(token='MY-APIFY-TOKEN', compression_algorithm='gzip', compression_quality=9)
61+
client = ApifyClient(token='MY-APIFY-TOKEN', encoding='brotli')
6362
```
6463

65-
Both `compression_algorithm` (`'brotli'` or `'gzip'`) and `compression_quality` (brotli: `1–11`, gzip: `1–9`, default `6`) are configurable on the client constructor.
64+
For fine-grained control over compression quality, inject a compressor instance directly:
65+
66+
```python
67+
from apify_client.http_compressors import BrotliHttpCompressor, GzipHttpCompressor
68+
69+
client = ApifyClient(token='MY-APIFY-TOKEN', encoding=BrotliHttpCompressor(quality=11))
70+
# or
71+
client = ApifyClient(token='MY-APIFY-TOKEN', encoding=GzipHttpCompressor(quality=9))
72+
```
6673

6774
- From [conda-forge](https://anaconda.org/conda-forge/apify-client), it can be installed with [conda](https://docs.conda.io/en/latest/):
6875

src/apify_client/_apify_client.py

Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
from apify_client._consts import (
88
API_VERSION,
99
DEFAULT_API_URL,
10-
DEFAULT_COMPRESSION_ALGORITHM,
11-
DEFAULT_COMPRESSION_QUALITY,
1210
DEFAULT_MAX_RETRIES,
1311
DEFAULT_MIN_DELAY_BETWEEN_RETRIES,
1412
DEFAULT_TIMEOUT_LONG,
@@ -76,11 +74,42 @@
7674
from apify_client._statistics import ClientStatistics
7775
from apify_client._utils import check_custom_headers
7876
from apify_client.http_clients import HttpClient, HttpClientAsync, ImpitHttpClient, ImpitHttpClientAsync
77+
from apify_client.http_compressors import GzipHttpCompressor
78+
from apify_client.http_compressors._base import HttpCompressor
7979

8080
if TYPE_CHECKING:
8181
from datetime import timedelta
8282

83-
from apify_client.types import CompressionAlgorithm
83+
from apify_client.types import HttpCompressionAlgorithm
84+
85+
86+
def _resolve_compressor(encoding: HttpCompressionAlgorithm | HttpCompressor) -> HttpCompressor:
87+
"""Convert an encoding string or `HttpCompressor` instance into a concrete `HttpCompressor`.
88+
89+
Args:
90+
encoding: Either a string literal (`'gzip'` or `'brotli'`) or an `HttpCompressor` instance.
91+
92+
Returns:
93+
A ready-to-use `HttpCompressor`.
94+
95+
Raises:
96+
ImportError: If `'brotli'` is requested but the `brotli` extra is not installed.
97+
ValueError: If `encoding` is not a recognized compression algorithm.
98+
"""
99+
if isinstance(encoding, HttpCompressor):
100+
return encoding
101+
elif encoding == 'gzip':
102+
return GzipHttpCompressor()
103+
elif encoding == 'brotli':
104+
# The import is here so the ImportError is raised at call time,
105+
# not at module import time, giving users a clear message.
106+
from apify_client.http_compressors import BrotliHttpCompressor # noqa: PLC0415
107+
108+
return BrotliHttpCompressor()
109+
else:
110+
# The backend supports also `deflate` and `identity` (no compression). One can build
111+
# a custom compressor if needed.
112+
raise ValueError(f'Unsupported compression algorithm: {encoding!r}')
84113

85114

86115
@docs_group('Apify API clients')
@@ -126,8 +155,7 @@ def __init__(
126155
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
127156
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
128157
headers: dict[str, str] | None = None,
129-
compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM,
130-
compression_quality: int = DEFAULT_COMPRESSION_QUALITY,
158+
encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip',
131159
) -> None:
132160
"""Initialize the Apify API client.
133161
@@ -149,11 +177,9 @@ def __init__(
149177
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
150178
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
151179
headers: Additional HTTP headers to include in all API requests.
152-
compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli
153-
when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always
154-
uses gzip regardless of whether the extra is installed.
155-
compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip.
156-
Defaults to `6`.
180+
encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or
181+
`'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control,
182+
pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`.
157183
"""
158184
# We need to do this because of mocking in tests and default mutable arguments.
159185
api_url = DEFAULT_API_URL if api_url is None else api_url
@@ -216,8 +242,7 @@ def __init__(
216242
self._timeout_long = timeout_long
217243
self._timeout_max = timeout_max
218244
self._headers = headers
219-
self._compression_algorithm = compression_algorithm
220-
self._compression_quality = compression_quality
245+
self._encoding = encoding
221246

222247
@classmethod
223248
def with_custom_http_client(
@@ -283,8 +308,7 @@ def http_client(self) -> HttpClient:
283308
min_delay_between_retries=self._min_delay_between_retries,
284309
statistics=self._statistics,
285310
headers=self._headers,
286-
compression_algorithm=self._compression_algorithm,
287-
compression_quality=self._compression_quality,
311+
compressor=_resolve_compressor(self._encoding),
288312
)
289313

290314
return self._http_client
@@ -491,8 +515,7 @@ def __init__(
491515
timeout_long: timedelta = DEFAULT_TIMEOUT_LONG,
492516
timeout_max: timedelta = DEFAULT_TIMEOUT_MAX,
493517
headers: dict[str, str] | None = None,
494-
compression_algorithm: CompressionAlgorithm = DEFAULT_COMPRESSION_ALGORITHM,
495-
compression_quality: int = DEFAULT_COMPRESSION_QUALITY,
518+
encoding: HttpCompressionAlgorithm | HttpCompressor = 'gzip',
496519
) -> None:
497520
"""Initialize the Apify API client.
498521
@@ -514,11 +537,9 @@ def __init__(
514537
timeout_long: Default timeout for long-duration API operations (long-polling, streaming, ...).
515538
timeout_max: Maximum timeout cap for exponential timeout growth across retries.
516539
headers: Additional HTTP headers to include in all API requests.
517-
compression_algorithm: Algorithm used to compress request bodies. `'brotli'` (default) uses brotli
518-
when the `brotli` extra is installed and falls back to gzip when unavailable. `'gzip'` always
519-
uses gzip regardless of whether the extra is installed.
520-
compression_quality: Compression quality level. Valid range is `1-11` for brotli and `1-9` for gzip.
521-
Defaults to `6`.
540+
encoding: Compression algorithm for request bodies. Pass `'gzip'` (default, no extra required) or
541+
`'brotli'` (requires `pip install "apify-client[brotli]"`). For custom quality or full control,
542+
pass an `HttpCompressor` instance directly, e.g. `BrotliHttpCompressor(quality=11)`.
522543
"""
523544
# We need to do this because of mocking in tests and default mutable arguments.
524545
api_url = DEFAULT_API_URL if api_url is None else api_url
@@ -581,8 +602,7 @@ def __init__(
581602
self._timeout_long = timeout_long
582603
self._timeout_max = timeout_max
583604
self._headers = headers
584-
self._compression_algorithm = compression_algorithm
585-
self._compression_quality = compression_quality
605+
self._encoding = encoding
586606

587607
@classmethod
588608
def with_custom_http_client(
@@ -648,8 +668,7 @@ def http_client(self) -> HttpClientAsync:
648668
min_delay_between_retries=self._min_delay_between_retries,
649669
statistics=self._statistics,
650670
headers=self._headers,
651-
compression_algorithm=self._compression_algorithm,
652-
compression_quality=self._compression_quality,
671+
compressor=_resolve_compressor(self._encoding),
653672
)
654673
return self._http_client
655674

src/apify_client/_consts.py

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
from __future__ import annotations
22

33
from datetime import timedelta
4-
from typing import TYPE_CHECKING
5-
6-
if TYPE_CHECKING:
7-
from apify_client.types import CompressionAlgorithm
84

95
DEFAULT_API_URL = 'https://api.apify.com'
106
"""Default base URL for the Apify API."""
@@ -38,21 +34,3 @@
3834

3935
OVERRIDABLE_DEFAULT_HEADERS = {'Accept', 'Authorization', 'Accept-Encoding', 'User-Agent'}
4036
"""Headers that can be overridden by users, but will trigger a warning if they do so, as it may lead to API errors."""
41-
42-
DEFAULT_COMPRESSION_ALGORITHM: CompressionAlgorithm = 'brotli'
43-
"""Default compression algorithm for request bodies."""
44-
45-
DEFAULT_COMPRESSION_QUALITY: int = 6
46-
"""Default compression quality for request bodies (brotli: 1-11, gzip: 1-9)."""
47-
48-
BROTLI_QUALITY_MIN: int = 1
49-
"""Minimum quality level for brotli compression."""
50-
51-
BROTLI_QUALITY_MAX: int = 11
52-
"""Maximum quality level for brotli compression."""
53-
54-
GZIP_QUALITY_MIN: int = 1
55-
"""Minimum quality level for gzip compression."""
56-
57-
GZIP_QUALITY_MAX: int = 9
58-
"""Maximum quality level for gzip compression."""

src/apify_client/_utils.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,14 @@
55
import io
66
import json
77
import string
8+
import sys
89
import time
910
import warnings
1011
from base64 import b64encode, urlsafe_b64encode
12+
from contextlib import contextmanager
13+
from dataclasses import dataclass
1114
from functools import cache
15+
from types import ModuleType
1216
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
1317

1418
import impit
@@ -18,6 +22,7 @@
1822
from apify_client.errors import InvalidResponseBodyError, NotFoundError
1923

2024
if TYPE_CHECKING:
25+
from collections.abc import Generator
2126
from datetime import timedelta
2227

2328
from apify_client.errors import ApifyApiError
@@ -27,7 +32,50 @@
2732
T = TypeVar('T')
2833

2934
_BASE62_CHARSET = string.digits + string.ascii_letters
30-
"""Module-level constant for base62 encoding."""
35+
36+
37+
@dataclass
38+
class _FailedImport:
39+
message: str
40+
41+
42+
class _ImportWrapper(ModuleType):
43+
"""Module subclass that converts `_FailedImport` attribute accesses into `ImportError`."""
44+
45+
def __getattr__(self, name: str) -> object:
46+
obj = super().__getattribute__(name)
47+
if isinstance(obj, _FailedImport):
48+
raise ImportError(obj.message) # noqa: TRY004
49+
return obj
50+
51+
52+
@contextmanager
53+
def try_import(module_name: str, symbol_name: str) -> Generator[None, None, None]:
54+
"""Context manager for optional imports.
55+
56+
If the import inside the block raises `ImportError`, the named symbol in the given module is
57+
replaced with a `_FailedImport` placeholder. Accessing that placeholder later (after
58+
`install_import_hook` has been called) raises a clear `ImportError` with the original message.
59+
60+
Args:
61+
module_name: Fully-qualified name of the module whose namespace to update (pass `__name__`).
62+
symbol_name: The name that would have been imported, used as the placeholder key.
63+
"""
64+
try:
65+
yield
66+
except ImportError as exc:
67+
sys.modules[module_name].__dict__[symbol_name] = _FailedImport(str(exc))
68+
69+
70+
def install_import_hook(module_name: str) -> None:
71+
"""Replace a module's class with `_ImportWrapper` to activate deferred `ImportError` raising.
72+
73+
Must be called after all `try_import` blocks in the same `__init__.py`.
74+
75+
Args:
76+
module_name: Fully-qualified name of the module to wrap (pass `__name__`).
77+
"""
78+
sys.modules[module_name].__class__ = _ImportWrapper
3179

3280

3381
@overload

0 commit comments

Comments
 (0)