Skip to content

Commit 3b6c712

Browse files
committed
refactor: split top level client from base client
Split the top level client used to gather all resource client in a single class, from the base client actually doing the requests to the API. This allows us for example to swap or modify the base client in a resource client, that might need a different base client (session, endpoint, headers, ...).
1 parent 2b4773e commit 3b6c712

File tree

5 files changed

+344
-257
lines changed

5 files changed

+344
-257
lines changed

hcloud/_client.py

Lines changed: 87 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ def func(retries: int) -> float:
7878
return func
7979

8080

81+
def _build_user_agent(
82+
application_name: str | None,
83+
application_version: str | None,
84+
) -> str:
85+
"""Build the user agent of the hcloud-python instance with the user application name (if specified)
86+
87+
:return: The user agent of this hcloud-python instance
88+
"""
89+
parts = []
90+
for name, version in [
91+
(application_name, application_version),
92+
("hcloud-python", __version__),
93+
]:
94+
if name is not None:
95+
parts.append(name if version is None else f"{name}/{version}")
96+
97+
return " ".join(parts)
98+
99+
81100
class Client:
82101
"""
83102
Client for the Hetzner Cloud API.
@@ -112,14 +131,6 @@ class Client:
112131
breaking changes.
113132
"""
114133

115-
_version = __version__
116-
__user_agent_prefix = "hcloud-python"
117-
118-
_retry_interval = staticmethod(
119-
exponential_backoff_function(base=1.0, multiplier=2, cap=60.0, jitter=True)
120-
)
121-
_retry_max_retries = 5
122-
123134
def __init__(
124135
self,
125136
token: str,
@@ -143,18 +154,15 @@ def __init__(
143154
Max retries before timeout when polling actions from the API.
144155
:param timeout: Requests timeout in seconds
145156
"""
146-
self.token = token
147-
self._api_endpoint = api_endpoint
148-
self._application_name = application_name
149-
self._application_version = application_version
150-
self._requests_session = requests.Session()
151-
self._requests_timeout = timeout
152-
153-
if isinstance(poll_interval, (int, float)):
154-
self._poll_interval_func = constant_backoff_function(poll_interval)
155-
else:
156-
self._poll_interval_func = poll_interval
157-
self._poll_max_retries = poll_max_retries
157+
self._client = ClientBase(
158+
token=token,
159+
endpoint=api_endpoint,
160+
application_name=application_name,
161+
application_version=application_version,
162+
poll_interval=poll_interval,
163+
poll_max_retries=poll_max_retries,
164+
timeout=timeout,
165+
)
158166

159167
self.datacenters = DatacentersClient(self)
160168
"""DatacentersClient Instance
@@ -246,50 +254,81 @@ def __init__(
246254
:type: :class:`PlacementGroupsClient <hcloud.placement_groups.client.PlacementGroupsClient>`
247255
"""
248256

249-
def _get_user_agent(self) -> str:
250-
"""Get the user agent of the hcloud-python instance with the user application name (if specified)
257+
def request( # type: ignore[no-untyped-def]
258+
self,
259+
method: str,
260+
url: str,
261+
**kwargs,
262+
) -> dict:
263+
"""Perform a request to the Hetzner Cloud API.
251264
252-
:return: The user agent of this hcloud-python instance
265+
:param method: Method to perform the request.
266+
:param url: URL to perform the request.
267+
:param timeout: Requests timeout in seconds.
253268
"""
254-
user_agents = []
255-
for name, version in [
256-
(self._application_name, self._application_version),
257-
(self.__user_agent_prefix, self._version),
258-
]:
259-
if name is not None:
260-
user_agents.append(name if version is None else f"{name}/{version}")
261-
262-
return " ".join(user_agents)
263-
264-
def _get_headers(self) -> dict:
265-
headers = {
266-
"User-Agent": self._get_user_agent(),
267-
"Authorization": f"Bearer {self.token}",
269+
return self._client.request(method, url, **kwargs)
270+
271+
272+
class ClientBase:
273+
def __init__(
274+
self,
275+
token: str,
276+
*,
277+
endpoint: str,
278+
application_name: str | None = None,
279+
application_version: str | None = None,
280+
poll_interval: int | float | BackoffFunction = 1.0,
281+
poll_max_retries: int = 120,
282+
timeout: float | tuple[float, float] | None = None,
283+
):
284+
self._token = token
285+
self._endpoint = endpoint
286+
287+
self._user_agent = _build_user_agent(application_name, application_version)
288+
self._headers = {
289+
"User-Agent": self._user_agent,
290+
"Authorization": f"Bearer {self._token}",
291+
"Accept": "application/json",
268292
}
269-
return headers
293+
294+
if isinstance(poll_interval, (int, float)):
295+
poll_interval_func = constant_backoff_function(poll_interval)
296+
else:
297+
poll_interval_func = poll_interval
298+
299+
self._poll_interval_func = poll_interval_func
300+
self._poll_max_retries = poll_max_retries
301+
302+
self._retry_interval_func = exponential_backoff_function(
303+
base=1.0, multiplier=2, cap=60.0, jitter=True
304+
)
305+
self._retry_max_retries = 5
306+
307+
self._timeout = timeout
308+
self._session = requests.Session()
270309

271310
def request( # type: ignore[no-untyped-def]
272311
self,
273312
method: str,
274313
url: str,
275314
**kwargs,
276315
) -> dict:
277-
"""Perform a request to the Hetzner Cloud API, wrapper around requests.request
316+
"""Perform a request to the provided URL.
278317
279-
:param method: HTTP Method to perform the Request
280-
:param url: URL of the Endpoint
281-
:param timeout: Requests timeout in seconds
318+
:param method: Method to perform the request.
319+
:param url: URL to perform the request.
320+
:param timeout: Requests timeout in seconds.
282321
:return: Response
283322
"""
284-
kwargs.setdefault("timeout", self._requests_timeout)
323+
kwargs.setdefault("timeout", self._timeout)
285324

286-
url = self._api_endpoint + url
287-
headers = self._get_headers()
325+
url = self._endpoint + url
326+
headers = self._headers
288327

289328
retries = 0
290329
while True:
291330
try:
292-
response = self._requests_session.request(
331+
response = self._session.request(
293332
method=method,
294333
url=url,
295334
headers=headers,
@@ -298,13 +337,13 @@ def request( # type: ignore[no-untyped-def]
298337
return self._read_response(response)
299338
except APIException as exception:
300339
if retries < self._retry_max_retries and self._retry_policy(exception):
301-
time.sleep(self._retry_interval(retries))
340+
time.sleep(self._retry_interval_func(retries))
302341
retries += 1
303342
continue
304343
raise
305344
except requests.exceptions.Timeout:
306345
if retries < self._retry_max_retries:
307-
time.sleep(self._retry_interval(retries))
346+
time.sleep(self._retry_interval_func(retries))
308347
retries += 1
309348
continue
310349
raise

hcloud/actions/client.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,16 @@ class ActionsPageResult(NamedTuple):
5353
class ResourceActionsClient(ResourceClientBase):
5454
_resource: str
5555

56-
def __init__(self, client: Client, resource: str | None):
57-
super().__init__(client)
56+
def __init__(self, client: ResourceClientBase | Client, resource: str | None):
57+
if isinstance(client, ResourceClientBase):
58+
super().__init__(client._parent)
59+
# Use the same base client as the the resource base client. Allows us to
60+
# choose the base client outside of the ResourceActionsClient.
61+
self._client = client._client
62+
else:
63+
# Backward compatibility, defaults to the parent ("top level") base client (`_client`).
64+
super().__init__(client)
65+
5866
self._resource = resource or ""
5967

6068
def get_by_id(self, id: int) -> BoundAction:

hcloud/core/client.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,20 @@
44
from typing import TYPE_CHECKING, Any, Callable
55

66
if TYPE_CHECKING:
7-
from .._client import Client
7+
from .._client import Client, ClientBase
88
from .domain import BaseDomain
99

1010

1111
class ResourceClientBase:
12-
_client: Client
12+
_parent: Client
13+
_client: ClientBase
1314

1415
max_per_page: int = 50
1516

1617
def __init__(self, client: Client):
17-
"""
18-
:param client: Client
19-
:return self
20-
"""
21-
self._client = client
18+
self._parent = client
19+
# Use the parent "default" base client.
20+
self._client = client._client
2221

2322
def _iter_pages( # type: ignore[no-untyped-def]
2423
self,

tests/unit/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
from hcloud import Client
1010

1111

12+
@pytest.fixture(autouse=True, scope="session")
13+
def patch_package_version():
14+
with mock.patch("hcloud._client.__version__", "0.0.0"):
15+
yield
16+
17+
1218
@pytest.fixture()
1319
def request_mock() -> mock.MagicMock:
1420
return mock.MagicMock()

0 commit comments

Comments
 (0)