Skip to content

Commit 4395115

Browse files
authored
refactor: split top level client from base client (#534) (#540)
Merges #534 into the storage-boxes branch for development purposes. Plus some required fixes after the merge.
1 parent ce802c7 commit 4395115

File tree

32 files changed

+560
-502
lines changed

32 files changed

+560
-502
lines changed

hcloud/_client.py

Lines changed: 89 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,25 @@ def func(retries: int) -> float:
7979
return func
8080

8181

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

116-
_version = __version__
117-
__user_agent_prefix = "hcloud-python"
118-
119-
_retry_interval = staticmethod(
120-
exponential_backoff_function(base=1.0, multiplier=2, cap=60.0, jitter=True)
121-
)
122-
_retry_max_retries = 5
123-
124135
def __init__(
125136
self,
126137
token: str,
@@ -147,19 +158,24 @@ def __init__(
147158
Max retries before timeout when polling actions from the API.
148159
:param timeout: Requests timeout in seconds
149160
"""
150-
self.token = token
151-
self._api_endpoint = api_endpoint
152-
self._api_endpoint_hetzner = api_endpoint_hetzner
153-
self._application_name = application_name
154-
self._application_version = application_version
155-
self._requests_session = requests.Session()
156-
self._requests_timeout = timeout
157-
158-
if isinstance(poll_interval, (int, float)):
159-
self._poll_interval_func = constant_backoff_function(poll_interval)
160-
else:
161-
self._poll_interval_func = poll_interval
162-
self._poll_max_retries = poll_max_retries
161+
self._client = ClientBase(
162+
token=token,
163+
endpoint=api_endpoint,
164+
application_name=application_name,
165+
application_version=application_version,
166+
poll_interval=poll_interval,
167+
poll_max_retries=poll_max_retries,
168+
timeout=timeout,
169+
)
170+
self._client_hetzner = ClientBase(
171+
token=token,
172+
endpoint=api_endpoint_hetzner,
173+
application_name=application_name,
174+
application_version=application_version,
175+
poll_interval=poll_interval,
176+
poll_max_retries=poll_max_retries,
177+
timeout=timeout,
178+
)
163179

164180
self.datacenters = DatacentersClient(self)
165181
"""DatacentersClient Instance
@@ -257,79 +273,81 @@ def __init__(
257273
:type: :class:`StorageBoxTypesClient <hcloud.storage_box_types.client.StorageBoxTypesClient>`
258274
"""
259275

260-
def _get_user_agent(self) -> str:
261-
"""Get the user agent of the hcloud-python instance with the user application name (if specified)
262-
263-
:return: The user agent of this hcloud-python instance
264-
"""
265-
user_agents = []
266-
for name, version in [
267-
(self._application_name, self._application_version),
268-
(self.__user_agent_prefix, self._version),
269-
]:
270-
if name is not None:
271-
user_agents.append(name if version is None else f"{name}/{version}")
272-
273-
return " ".join(user_agents)
274-
275-
def _get_headers(self) -> dict:
276-
headers = {
277-
"User-Agent": self._get_user_agent(),
278-
"Authorization": f"Bearer {self.token}",
279-
}
280-
return headers
281-
282276
def request( # type: ignore[no-untyped-def]
283277
self,
284278
method: str,
285279
url: str,
286280
**kwargs,
287281
) -> dict:
288-
"""Perform a request to the Hetzner Cloud API, wrapper around requests.request
282+
"""Perform a request to the Hetzner Cloud API.
289283
290-
:param method: Method to perform the request
291-
:param url: URL of the endpoint
292-
:param timeout: Requests timeout in seconds
293-
:return: Response
284+
:param method: Method to perform the request.
285+
:param url: URL to perform the request.
286+
:param timeout: Requests timeout in seconds.
294287
"""
295-
return self._request(method, self._api_endpoint + url, **kwargs)
288+
return self._client.request(method, url, **kwargs)
296289

297-
def _request_hetzner( # type: ignore[no-untyped-def]
290+
291+
class ClientBase:
292+
def __init__(
298293
self,
299-
method: str,
300-
url: str,
301-
**kwargs,
302-
) -> dict:
303-
"""Perform a request to the Hetzner API, wrapper around requests.request
294+
token: str,
295+
*,
296+
endpoint: str,
297+
application_name: str | None = None,
298+
application_version: str | None = None,
299+
poll_interval: int | float | BackoffFunction = 1.0,
300+
poll_max_retries: int = 120,
301+
timeout: float | tuple[float, float] | None = None,
302+
):
303+
self._token = token
304+
self._endpoint = endpoint
305+
306+
self._user_agent = _build_user_agent(application_name, application_version)
307+
self._headers = {
308+
"User-Agent": self._user_agent,
309+
"Authorization": f"Bearer {self._token}",
310+
"Accept": "application/json",
311+
}
304312

305-
:param method: Method to perform the request
306-
:param url: URL of the endpoint
307-
:param timeout: Requests timeout in seconds
308-
:return: Response
309-
"""
310-
return self._request(method, self._api_endpoint_hetzner + url, **kwargs)
313+
if isinstance(poll_interval, (int, float)):
314+
poll_interval_func = constant_backoff_function(poll_interval)
315+
else:
316+
poll_interval_func = poll_interval
317+
318+
self._poll_interval_func = poll_interval_func
319+
self._poll_max_retries = poll_max_retries
320+
321+
self._retry_interval_func = exponential_backoff_function(
322+
base=1.0, multiplier=2, cap=60.0, jitter=True
323+
)
324+
self._retry_max_retries = 5
325+
326+
self._timeout = timeout
327+
self._session = requests.Session()
311328

312-
def _request( # type: ignore[no-untyped-def]
329+
def request( # type: ignore[no-untyped-def]
313330
self,
314331
method: str,
315332
url: str,
316333
**kwargs,
317334
) -> dict:
318-
"""Perform a request to the provided URL, wrapper around requests.request
335+
"""Perform a request to the provided URL.
319336
320-
:param method: Method to perform the request
321-
:param url: URL to perform the request
322-
:param timeout: Requests timeout in seconds
337+
:param method: Method to perform the request.
338+
:param url: URL to perform the request.
339+
:param timeout: Requests timeout in seconds.
323340
:return: Response
324341
"""
325-
kwargs.setdefault("timeout", self._requests_timeout)
342+
kwargs.setdefault("timeout", self._timeout)
326343

327-
headers = self._get_headers()
344+
url = self._endpoint + url
345+
headers = self._headers
328346

329347
retries = 0
330348
while True:
331349
try:
332-
response = self._requests_session.request(
350+
response = self._session.request(
333351
method=method,
334352
url=url,
335353
headers=headers,
@@ -338,13 +356,13 @@ def _request( # type: ignore[no-untyped-def]
338356
return self._read_response(response)
339357
except APIException as exception:
340358
if retries < self._retry_max_retries and self._retry_policy(exception):
341-
time.sleep(self._retry_interval(retries))
359+
time.sleep(self._retry_interval_func(retries))
342360
retries += 1
343361
continue
344362
raise
345363
except requests.exceptions.Timeout:
346364
if retries < self._retry_max_retries:
347-
time.sleep(self._retry_interval(retries))
365+
time.sleep(self._retry_interval_func(retries))
348366
retries += 1
349367
continue
350368
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)