Skip to content

Commit 4698975

Browse files
authored
refactor: split top level client from base client (#534)
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 4698975

File tree

31 files changed

+552
-475
lines changed

31 files changed

+552
-475
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: 12 additions & 4 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:
@@ -67,7 +75,7 @@ def get_by_id(self, id: int) -> BoundAction:
6775
url=f"{self._resource}/actions/{id}",
6876
method="GET",
6977
)
70-
return BoundAction(self._client.actions, response["action"])
78+
return BoundAction(self._parent.actions, response["action"])
7179

7280
def get_list(
7381
self,
@@ -104,7 +112,7 @@ def get_list(
104112
params=params,
105113
)
106114
actions = [
107-
BoundAction(self._client.actions, action_data)
115+
BoundAction(self._parent.actions, action_data)
108116
for action_data in response["actions"]
109117
]
110118
return ActionsPageResult(actions, Meta.parse_meta(response))

hcloud/certificates/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,6 @@ class CertificatesPageResult(NamedTuple):
104104

105105

106106
class CertificatesClient(ResourceClientBase):
107-
_client: Client
108107

109108
actions: ResourceActionsClient
110109
"""Certificates scoped actions client
@@ -248,7 +247,7 @@ def create_managed(
248247
response = self._client.request(url="/certificates", method="POST", json=data)
249248
return CreateManagedCertificateResponse(
250249
certificate=BoundCertificate(self, response["certificate"]),
251-
action=BoundAction(self._client.actions, response["action"]),
250+
action=BoundAction(self._parent.actions, response["action"]),
252251
)
253252

254253
def update(
@@ -328,7 +327,7 @@ def get_actions_list(
328327
params=params,
329328
)
330329
actions = [
331-
BoundAction(self._client.actions, action_data)
330+
BoundAction(self._parent.actions, action_data)
332331
for action_data in response["actions"]
333332
]
334333
return ActionsPageResult(actions, Meta.parse_meta(response))
@@ -368,4 +367,4 @@ def retry_issuance(
368367
url=f"/certificates/{certificate.id}/actions/retry",
369368
method="POST",
370369
)
371-
return BoundAction(self._client.actions, response["action"])
370+
return BoundAction(self._parent.actions, response["action"])

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,

hcloud/datacenters/client.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Any, NamedTuple
3+
from typing import Any, NamedTuple
44

55
from ..core import BoundModelBase, Meta, ResourceClientBase
66
from ..locations import BoundLocation
77
from ..server_types import BoundServerType
88
from .domain import Datacenter, DatacenterServerTypes
99

10-
if TYPE_CHECKING:
11-
from .._client import Client
12-
1310

1411
class BoundDatacenter(BoundModelBase, Datacenter):
1512
_client: DatacentersClient
@@ -19,25 +16,25 @@ class BoundDatacenter(BoundModelBase, Datacenter):
1916
def __init__(self, client: DatacentersClient, data: dict):
2017
location = data.get("location")
2118
if location is not None:
22-
data["location"] = BoundLocation(client._client.locations, location)
19+
data["location"] = BoundLocation(client._parent.locations, location)
2320

2421
server_types = data.get("server_types")
2522
if server_types is not None:
2623
available = [
2724
BoundServerType(
28-
client._client.server_types, {"id": server_type}, complete=False
25+
client._parent.server_types, {"id": server_type}, complete=False
2926
)
3027
for server_type in server_types["available"]
3128
]
3229
supported = [
3330
BoundServerType(
34-
client._client.server_types, {"id": server_type}, complete=False
31+
client._parent.server_types, {"id": server_type}, complete=False
3532
)
3633
for server_type in server_types["supported"]
3734
]
3835
available_for_migration = [
3936
BoundServerType(
40-
client._client.server_types, {"id": server_type}, complete=False
37+
client._parent.server_types, {"id": server_type}, complete=False
4138
)
4239
for server_type in server_types["available_for_migration"]
4340
]
@@ -56,7 +53,6 @@ class DatacentersPageResult(NamedTuple):
5653

5754

5855
class DatacentersClient(ResourceClientBase):
59-
_client: Client
6056

6157
def get_by_id(self, id: int) -> BoundDatacenter:
6258
"""Get a specific datacenter by its ID.

0 commit comments

Comments
 (0)