From ba7cbaecebb620398038ce4207b21770c82b00fa Mon Sep 17 00:00:00 2001 From: jo Date: Fri, 15 Aug 2025 10:31:52 +0200 Subject: [PATCH 1/2] refactor: split top level client from base client (#534) Squashed commit of the following: commit 4ff9fbcccb50bd5cbf78e7d955ec638e1591c478 Author: jo Date: Thu Aug 14 16:54:47 2025 +0200 test: timeout handling commit 13f7c0bba5c16cb77b607501e1106b0ef47811ac Author: jo Date: Thu Aug 14 16:33:26 2025 +0200 test: fix speed optimization commit 458631d619450a1fbd10c959a02cb7b408cb90a8 Author: jo Date: Thu Aug 14 16:29:50 2025 +0200 test: use parent to access other resources clients commit f69a609b3ae7fa069576a7f56271c88ed8bfbad1 Author: jo Date: Thu Aug 14 16:28:13 2025 +0200 test: update request_mock fixture commit d5652399f90ee2fdee94a8c1584595f83a1ef789 Author: jo Date: Tue Aug 12 15:57:25 2025 +0200 refactor: use parent to access other resources clients (2) commit 170398524645b5f9d8d0b3a6a732a1f7f8c3e89a Author: jo Date: Thu Aug 14 16:25:51 2025 +0200 refactor: use parent to access other resources clients (1) commit adb92324a68a785f3241669fb9c09166fbfc8e24 Author: jo Date: Thu Aug 14 16:24:50 2025 +0200 refactor: use type from inherited resource client property commit 3b6c7123ea4bd66edd144f87ce337aa3f097da5a Author: jo Date: Thu Aug 14 16:18:29 2025 +0200 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, ...). --- hcloud/_client.py | 151 ++++---- hcloud/actions/client.py | 16 +- hcloud/certificates/client.py | 7 +- hcloud/core/client.py | 13 +- hcloud/datacenters/client.py | 14 +- hcloud/firewalls/client.py | 15 +- hcloud/floating_ips/client.py | 17 +- hcloud/images/client.py | 9 +- hcloud/isos/client.py | 6 +- hcloud/load_balancer_types/client.py | 6 +- hcloud/load_balancers/client.py | 41 +- hcloud/locations/client.py | 6 +- hcloud/networks/client.py | 17 +- hcloud/placement_groups/client.py | 8 +- hcloud/primary_ips/client.py | 13 +- hcloud/server_types/client.py | 6 +- hcloud/servers/client.py | 79 ++-- hcloud/ssh_keys/client.py | 6 +- hcloud/volumes/client.py | 19 +- tests/unit/actions/test_client.py | 24 +- tests/unit/certificates/test_client.py | 6 +- tests/unit/conftest.py | 16 +- tests/unit/firewalls/test_client.py | 8 +- tests/unit/floating_ips/test_client.py | 8 +- tests/unit/images/test_client.py | 8 +- tests/unit/load_balancers/test_client.py | 6 +- tests/unit/networks/test_client.py | 8 +- tests/unit/primary_ips/test_client.py | 6 +- tests/unit/servers/test_client.py | 36 +- tests/unit/test_client.py | 461 +++++++++++++---------- tests/unit/volumes/test_client.py | 8 +- 31 files changed, 545 insertions(+), 499 deletions(-) diff --git a/hcloud/_client.py b/hcloud/_client.py index b44891e6..5e2b400d 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -79,6 +79,25 @@ def func(retries: int) -> float: return func +def _build_user_agent( + application_name: str | None, + application_version: str | None, +) -> str: + """Build the user agent of the hcloud-python instance with the user application name (if specified) + + :return: The user agent of this hcloud-python instance + """ + parts = [] + for name, version in [ + (application_name, application_version), + ("hcloud-python", __version__), + ]: + if name is not None: + parts.append(name if version is None else f"{name}/{version}") + + return " ".join(parts) + + class Client: """ Client for the Hetzner Cloud API. @@ -113,14 +132,6 @@ class Client: breaking changes. """ - _version = __version__ - __user_agent_prefix = "hcloud-python" - - _retry_interval = staticmethod( - exponential_backoff_function(base=1.0, multiplier=2, cap=60.0, jitter=True) - ) - _retry_max_retries = 5 - def __init__( self, token: str, @@ -147,19 +158,15 @@ def __init__( Max retries before timeout when polling actions from the API. :param timeout: Requests timeout in seconds """ - self.token = token - self._api_endpoint = api_endpoint - self._api_endpoint_hetzner = api_endpoint_hetzner - self._application_name = application_name - self._application_version = application_version - self._requests_session = requests.Session() - self._requests_timeout = timeout - - if isinstance(poll_interval, (int, float)): - self._poll_interval_func = constant_backoff_function(poll_interval) - else: - self._poll_interval_func = poll_interval - self._poll_max_retries = poll_max_retries + self._client = ClientBase( + token=token, + endpoint=api_endpoint, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) self.datacenters = DatacentersClient(self) """DatacentersClient Instance @@ -257,79 +264,81 @@ def __init__( :type: :class:`StorageBoxTypesClient ` """ - def _get_user_agent(self) -> str: - """Get the user agent of the hcloud-python instance with the user application name (if specified) - - :return: The user agent of this hcloud-python instance - """ - user_agents = [] - for name, version in [ - (self._application_name, self._application_version), - (self.__user_agent_prefix, self._version), - ]: - if name is not None: - user_agents.append(name if version is None else f"{name}/{version}") - - return " ".join(user_agents) - - def _get_headers(self) -> dict: - headers = { - "User-Agent": self._get_user_agent(), - "Authorization": f"Bearer {self.token}", - } - return headers - def request( # type: ignore[no-untyped-def] self, method: str, url: str, **kwargs, ) -> dict: - """Perform a request to the Hetzner Cloud API, wrapper around requests.request + """Perform a request to the Hetzner Cloud API. - :param method: Method to perform the request - :param url: URL of the endpoint - :param timeout: Requests timeout in seconds - :return: Response + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. """ - return self._request(method, self._api_endpoint + url, **kwargs) + return self._client.request(method, url, **kwargs) - def _request_hetzner( # type: ignore[no-untyped-def] + +class ClientBase: + def __init__( self, - method: str, - url: str, - **kwargs, - ) -> dict: - """Perform a request to the Hetzner API, wrapper around requests.request + token: str, + *, + endpoint: str, + application_name: str | None = None, + application_version: str | None = None, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, + timeout: float | tuple[float, float] | None = None, + ): + self._token = token + self._endpoint = endpoint + + self._user_agent = _build_user_agent(application_name, application_version) + self._headers = { + "User-Agent": self._user_agent, + "Authorization": f"Bearer {self._token}", + "Accept": "application/json", + } - :param method: Method to perform the request - :param url: URL of the endpoint - :param timeout: Requests timeout in seconds - :return: Response - """ - return self._request(method, self._api_endpoint_hetzner + url, **kwargs) + if isinstance(poll_interval, (int, float)): + poll_interval_func = constant_backoff_function(poll_interval) + else: + poll_interval_func = poll_interval + + self._poll_interval_func = poll_interval_func + self._poll_max_retries = poll_max_retries + + self._retry_interval_func = exponential_backoff_function( + base=1.0, multiplier=2, cap=60.0, jitter=True + ) + self._retry_max_retries = 5 + + self._timeout = timeout + self._session = requests.Session() - def _request( # type: ignore[no-untyped-def] + def request( # type: ignore[no-untyped-def] self, method: str, url: str, **kwargs, ) -> dict: - """Perform a request to the provided URL, wrapper around requests.request + """Perform a request to the provided URL. - :param method: Method to perform the request - :param url: URL to perform the request - :param timeout: Requests timeout in seconds + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. :return: Response """ - kwargs.setdefault("timeout", self._requests_timeout) + kwargs.setdefault("timeout", self._timeout) - headers = self._get_headers() + url = self._endpoint + url + headers = self._headers retries = 0 while True: try: - response = self._requests_session.request( + response = self._session.request( method=method, url=url, headers=headers, @@ -338,13 +347,13 @@ def _request( # type: ignore[no-untyped-def] return self._read_response(response) except APIException as exception: if retries < self._retry_max_retries and self._retry_policy(exception): - time.sleep(self._retry_interval(retries)) + time.sleep(self._retry_interval_func(retries)) retries += 1 continue raise except requests.exceptions.Timeout: if retries < self._retry_max_retries: - time.sleep(self._retry_interval(retries)) + time.sleep(self._retry_interval_func(retries)) retries += 1 continue raise diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index e9033323..a0c0cc49 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -53,8 +53,16 @@ class ActionsPageResult(NamedTuple): class ResourceActionsClient(ResourceClientBase): _resource: str - def __init__(self, client: Client, resource: str | None): - super().__init__(client) + def __init__(self, client: ResourceClientBase | Client, resource: str | None): + if isinstance(client, ResourceClientBase): + super().__init__(client._parent) + # Use the same base client as the the resource base client. Allows us to + # choose the base client outside of the ResourceActionsClient. + self._client = client._client + else: + # Backward compatibility, defaults to the parent ("top level") base client (`_client`). + super().__init__(client) + self._resource = resource or "" def get_by_id(self, id: int) -> BoundAction: @@ -67,7 +75,7 @@ def get_by_id(self, id: int) -> BoundAction: url=f"{self._resource}/actions/{id}", method="GET", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def get_list( self, @@ -104,7 +112,7 @@ def get_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 06423995..72856ba8 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -104,7 +104,6 @@ class CertificatesPageResult(NamedTuple): class CertificatesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Certificates scoped actions client @@ -248,7 +247,7 @@ def create_managed( response = self._client.request(url="/certificates", method="POST", json=data) return CreateManagedCertificateResponse( certificate=BoundCertificate(self, response["certificate"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -328,7 +327,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -368,4 +367,4 @@ def retry_issuance( url=f"/certificates/{certificate.id}/actions/retry", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index b2f10ee1..ab91611e 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -4,21 +4,20 @@ from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: - from .._client import Client + from .._client import Client, ClientBase from .domain import BaseDomain class ResourceClientBase: - _client: Client + _parent: Client + _client: ClientBase max_per_page: int = 50 def __init__(self, client: Client): - """ - :param client: Client - :return self - """ - self._client = client + self._parent = client + # Use the parent "default" base client. + self._client = client._client def _iter_pages( # type: ignore[no-untyped-def] self, diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 4a1b0434..0d793cf3 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,15 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from ..server_types import BoundServerType from .domain import Datacenter, DatacenterServerTypes -if TYPE_CHECKING: - from .._client import Client - class BoundDatacenter(BoundModelBase, Datacenter): _client: DatacentersClient @@ -19,25 +16,25 @@ class BoundDatacenter(BoundModelBase, Datacenter): def __init__(self, client: DatacentersClient, data: dict): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) server_types = data.get("server_types") if server_types is not None: available = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available"] ] supported = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["supported"] ] available_for_migration = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available_for_migration"] ] @@ -56,7 +53,6 @@ class DatacentersPageResult(NamedTuple): class DatacentersClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 2157e8f5..94b4cbd6 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -52,7 +52,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): type=resource["type"], server=( BoundServer( - client._client.servers, + client._parent.servers, resource.get("server"), complete=False, ) @@ -68,7 +68,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): FirewallResource( type=firewall_resource["type"], server=BoundServer( - client._client.servers, + client._parent.servers, firewall_resource["server"], complete=False, ), @@ -184,7 +184,6 @@ class FirewallsPageResult(NamedTuple): class FirewallsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Firewalls scoped actions client @@ -232,7 +231,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -378,7 +377,7 @@ def create( actions = [] if response.get("actions") is not None: actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -448,7 +447,7 @@ def set_rules( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -472,7 +471,7 @@ def apply_to_resources( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -496,6 +495,6 @@ def remove_from_resources( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 824cfdbb..c1ce8403 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -25,13 +25,13 @@ def __init__(self, client: FloatingIPsClient, data: dict, complete: bool = True) server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) home_location = data.get("home_location") if home_location is not None: data["home_location"] = BoundLocation( - client._client.locations, home_location + client._parent.locations, home_location ) super().__init__(client, data, complete) @@ -140,7 +140,6 @@ class FloatingIPsPageResult(NamedTuple): class FloatingIPsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Floating IPs scoped actions client @@ -188,7 +187,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -329,7 +328,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreateFloatingIPResponse( floating_ip=BoundFloatingIP(self, response["floating_ip"]), action=action @@ -403,7 +402,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -422,7 +421,7 @@ def assign( method="POST", json={"server": server.id}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: """Unassigns a Floating IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -434,7 +433,7 @@ def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: url=f"/floating_ips/{floating_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -456,4 +455,4 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/images/client.py b/hcloud/images/client.py index c7af92d9..ea4e66dd 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -23,12 +23,12 @@ def __init__(self, client: ImagesClient, data: dict): created_from = data.get("created_from") if created_from is not None: data["created_from"] = BoundServer( - client._client.servers, created_from, complete=False + client._parent.servers, created_from, complete=False ) bound_to = data.get("bound_to") if bound_to is not None: data["bound_to"] = BoundServer( - client._client.servers, {"id": bound_to}, complete=False + client._parent.servers, {"id": bound_to}, complete=False ) super().__init__(client, data) @@ -113,7 +113,6 @@ class ImagesPageResult(NamedTuple): class ImagesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Images scoped actions client @@ -161,7 +160,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -406,4 +405,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index abb28617..25c20c34 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Iso -if TYPE_CHECKING: - from .._client import Client - class BoundIso(BoundModelBase, Iso): _client: IsosClient @@ -21,7 +18,6 @@ class IsosPageResult(NamedTuple): class IsosClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundIso: """Get a specific ISO by its id diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 3691e419..a237cb11 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import LoadBalancerType -if TYPE_CHECKING: - from .._client import Client - class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): _client: LoadBalancerTypesClient @@ -21,7 +18,6 @@ class LoadBalancerTypesPageResult(NamedTuple): class LoadBalancerTypesClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundLoadBalancerType: """Returns a specific Load Balancer Type. diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 5553352f..7e42a325 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -64,7 +64,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -81,7 +81,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru tmp_target = LoadBalancerTarget(type=target["type"]) if target["type"] == "server": tmp_target.server = BoundServer( - client._client.servers, data=target["server"], complete=False + client._parent.servers, data=target["server"], complete=False ) tmp_target.use_private_ip = target["use_private_ip"] elif target["type"] == "label_selector": @@ -124,7 +124,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru ) tmp_service.http.certificates = [ BoundCertificate( - client._client.certificates, + client._parent.certificates, {"id": certificate}, complete=False, ) @@ -152,12 +152,12 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru load_balancer_type = data.get("load_balancer_type") if load_balancer_type is not None: data["load_balancer_type"] = BoundLoadBalancerType( - client._client.load_balancer_types, load_balancer_type + client._parent.load_balancer_types, load_balancer_type ) location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) super().__init__(client, data, complete) @@ -370,7 +370,6 @@ class LoadBalancersPageResult(NamedTuple): class LoadBalancersClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Load Balancers scoped actions client @@ -519,7 +518,7 @@ def create( return CreateLoadBalancerResponse( load_balancer=BoundLoadBalancer(self, response["load_balancer"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -638,7 +637,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -684,7 +683,7 @@ def add_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def update_service( self, @@ -704,7 +703,7 @@ def update_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_service( self, @@ -725,7 +724,7 @@ def delete_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_target( self, @@ -746,7 +745,7 @@ def add_target( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_target( self, @@ -769,7 +768,7 @@ def remove_target( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_algorithm( self, @@ -790,7 +789,7 @@ def change_algorithm( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -812,7 +811,7 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -835,7 +834,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_to_network( self, @@ -860,7 +859,7 @@ def attach_to_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -879,7 +878,7 @@ def detach_from_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_public_interface( self, @@ -896,7 +895,7 @@ def enable_public_interface( url=f"/load_balancers/{load_balancer.id}/actions/enable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_public_interface( self, @@ -913,7 +912,7 @@ def disable_public_interface( url=f"/load_balancers/{load_balancer.id}/actions/disable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_type( self, @@ -933,4 +932,4 @@ def change_type( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 6adb980a..11d83ca6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Location -if TYPE_CHECKING: - from .._client import Client - class BoundLocation(BoundModelBase, Location): _client: LocationsClient @@ -21,7 +18,6 @@ class LocationsPageResult(NamedTuple): class LocationsClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundLocation: """Get a specific location by its ID. diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index b8248d7a..23059b89 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -32,7 +32,7 @@ def __init__(self, client: NetworksClient, data: dict, complete: bool = True): servers = data.get("servers", []) if servers is not None: servers = [ - BoundServer(client._client.servers, {"id": server}, complete=False) + BoundServer(client._parent.servers, {"id": server}, complete=False) for server in servers ] data["servers"] = servers @@ -167,7 +167,6 @@ class NetworksPageResult(NamedTuple): class NetworksClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Networks scoped actions client @@ -387,7 +386,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -440,7 +439,7 @@ def add_subnet( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_subnet( self, @@ -461,7 +460,7 @@ def delete_subnet( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_route( self, @@ -485,7 +484,7 @@ def add_route( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_route( self, @@ -509,7 +508,7 @@ def delete_route( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_ip_range( self, @@ -530,7 +529,7 @@ def change_ip_range( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -553,4 +552,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index a5524afe..48894326 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -1,14 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..actions import BoundAction from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePlacementGroupResponse, PlacementGroup -if TYPE_CHECKING: - from .._client import Client - class BoundPlacementGroup(BoundModelBase, PlacementGroup): _client: PlacementGroupsClient @@ -44,7 +41,6 @@ class PlacementGroupsPageResult(NamedTuple): class PlacementGroupsClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundPlacementGroup: """Returns a specific Placement Group object @@ -164,7 +160,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePlacementGroupResponse( placement_group=BoundPlacementGroup(self, response["placement_group"]), diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index b3985e56..7bf4f4cc 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -22,7 +22,7 @@ def __init__(self, client: PrimaryIPsClient, data: dict, complete: bool = True): datacenter = data.get("datacenter", {}) if datacenter: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) super().__init__(client, data, complete) @@ -98,7 +98,6 @@ class PrimaryIPsPageResult(NamedTuple): class PrimaryIPsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Primary IPs scoped actions client @@ -225,7 +224,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePrimaryIPResponse( primary_ip=BoundPrimaryIP(self, response["primary_ip"]), action=action @@ -299,7 +298,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -321,7 +320,7 @@ def assign( method="POST", json={"assignee_id": assignee_id, "assignee_type": assignee_type}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: """Unassigns a Primary IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -333,7 +332,7 @@ def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: url=f"/primary_ips/{primary_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -355,4 +354,4 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 1f1a7de3..3f9df22c 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ServerType -if TYPE_CHECKING: - from .._client import Client - class BoundServerType(BoundModelBase, ServerType): _client: ServerTypesClient @@ -21,7 +18,6 @@ class ServerTypesPageResult(NamedTuple): class ServerTypesClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundServerType: """Returns a specific Server Type. diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 89c0c4eb..b4f7e050 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -57,28 +57,28 @@ class BoundServer(BoundModelBase, Server): def __init__(self, client: ServersClient, data: dict, complete: bool = True): datacenter = data.get("datacenter") if datacenter is not None: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) volumes = data.get("volumes", []) if volumes: volumes = [ - BoundVolume(client._client.volumes, {"id": volume}, complete=False) + BoundVolume(client._parent.volumes, {"id": volume}, complete=False) for volume in volumes ] data["volumes"] = volumes image = data.get("image", None) if image is not None: - data["image"] = BoundImage(client._client.images, image) + data["image"] = BoundImage(client._parent.images, image) iso = data.get("iso", None) if iso is not None: - data["iso"] = BoundIso(client._client.isos, iso) + data["iso"] = BoundIso(client._parent.isos, iso) server_type = data.get("server_type") if server_type is not None: data["server_type"] = BoundServerType( - client._client.server_types, server_type + client._parent.server_types, server_type ) public_net = data.get("public_net") @@ -90,7 +90,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv4_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv4"]["id"]}, complete=False, ) @@ -104,7 +104,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv6_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv6"]["id"]}, complete=False, ) @@ -113,14 +113,14 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) floating_ips = [ BoundFloatingIP( - client._client.floating_ips, {"id": floating_ip}, complete=False + client._parent.floating_ips, {"id": floating_ip}, complete=False ) for floating_ip in public_net["floating_ips"] ] firewalls = [ PublicNetworkFirewall( BoundFirewall( - client._client.firewalls, {"id": firewall["id"]}, complete=False + client._parent.firewalls, {"id": firewall["id"]}, complete=False ), status=firewall["status"], ) @@ -143,7 +143,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -158,7 +158,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): placement_group = data.get("placement_group") if placement_group: placement_group = BoundPlacementGroup( - client._client.placement_groups, placement_group + client._parent.placement_groups, placement_group ) data["placement_group"] = placement_group @@ -483,7 +483,6 @@ class ServersPageResult(NamedTuple): class ServersClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Servers scoped actions client @@ -669,9 +668,9 @@ def create( result = CreateServerResponse( server=BoundServer(self, response["server"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], root_password=response["root_password"], @@ -715,7 +714,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -816,7 +815,7 @@ def delete(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request(url=f"/servers/{server.id}", method="DELETE") - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def power_off(self, server: Server | BoundServer) -> BoundAction: """Cuts power to the server. This forcefully stops it without giving the server operating system time to gracefully stop @@ -828,7 +827,7 @@ def power_off(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/poweroff", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def power_on(self, server: Server | BoundServer) -> BoundAction: """Starts a server by turning its power on. @@ -840,7 +839,7 @@ def power_on(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/poweron", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reboot(self, server: Server | BoundServer) -> BoundAction: """Reboots a server gracefully by sending an ACPI request. @@ -852,7 +851,7 @@ def reboot(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/reboot", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset(self, server: Server | BoundServer) -> BoundAction: """Cuts power to a server and starts it again. @@ -864,7 +863,7 @@ def reset(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/reset", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def shutdown(self, server: Server | BoundServer) -> BoundAction: """Shuts down a server gracefully by sending an ACPI shutdown request. @@ -876,7 +875,7 @@ def shutdown(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/shutdown", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: """Resets the root password. Only works for Linux systems that are running the qemu guest agent. @@ -889,7 +888,7 @@ def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: method="POST", ) return ResetPasswordResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -917,7 +916,7 @@ def change_type( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_rescue( self, @@ -945,7 +944,7 @@ def enable_rescue( json=data, ) return EnableRescueResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -959,7 +958,7 @@ def disable_rescue(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/disable_rescue", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def create_image( self, @@ -996,8 +995,8 @@ def create_image( json=data, ) return CreateImageResponse( - action=BoundAction(self._client.actions, response["action"]), - image=BoundImage(self._client.images, response["image"]), + action=BoundAction(self._parent.actions, response["action"]), + image=BoundImage(self._parent.images, response["image"]), ) def rebuild( @@ -1020,7 +1019,7 @@ def rebuild( ) return RebuildResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response.get("root_password"), ) @@ -1034,7 +1033,7 @@ def enable_backup(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/enable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_backup(self, server: Server | BoundServer) -> BoundAction: """Disables the automatic backup option and deletes all existing Backups for a Server. @@ -1046,7 +1045,7 @@ def disable_backup(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/disable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_iso( self, @@ -1065,7 +1064,7 @@ def attach_iso( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_iso(self, server: Server | BoundServer) -> BoundAction: """Detaches an ISO from a server. @@ -1077,7 +1076,7 @@ def detach_iso(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/detach_iso", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -1100,7 +1099,7 @@ def change_dns_ptr( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -1128,7 +1127,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def request_console(self, server: Server | BoundServer) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. @@ -1141,7 +1140,7 @@ def request_console(self, server: Server | BoundServer) -> RequestConsoleRespons method="POST", ) return RequestConsoleResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), wss_url=response["wss_url"], password=response["password"], ) @@ -1173,7 +1172,7 @@ def attach_to_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -1192,7 +1191,7 @@ def detach_from_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_alias_ips( self, @@ -1214,7 +1213,7 @@ def change_alias_ips( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_to_placement_group( self, @@ -1233,7 +1232,7 @@ def add_to_placement_group( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_from_placement_group(self, server: Server | BoundServer) -> BoundAction: """Removes a server from a placement group. @@ -1245,4 +1244,4 @@ def remove_from_placement_group(self, server: Server | BoundServer) -> BoundActi url=f"/servers/{server.id}/actions/remove_from_placement_group", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index a68b5153..5fe355c1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import SSHKey -if TYPE_CHECKING: - from .._client import Client - class BoundSSHKey(BoundModelBase, SSHKey): _client: SSHKeysClient @@ -42,7 +39,6 @@ class SSHKeysPageResult(NamedTuple): class SSHKeysClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundSSHKey: """Get a specific SSH Key by its ID diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 15a71c4d..2420d7d2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -21,7 +21,7 @@ class BoundVolume(BoundModelBase, Volume): def __init__(self, client: VolumesClient, data: dict, complete: bool = True): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) # pylint: disable=import-outside-toplevel from ..servers import BoundServer @@ -29,7 +29,7 @@ def __init__(self, client: VolumesClient, data: dict, complete: bool = True): server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) super().__init__(client, data, complete) @@ -136,7 +136,6 @@ class VolumesPageResult(NamedTuple): class VolumesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Volumes scoped actions client @@ -275,9 +274,9 @@ def create( result = CreateVolumeResponse( volume=BoundVolume(self, response["volume"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], ) @@ -320,7 +319,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -396,7 +395,7 @@ def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: json={"size": size}, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def attach( self, @@ -420,7 +419,7 @@ def attach( json=data, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def detach(self, volume: Volume | BoundVolume) -> BoundAction: """Detaches a volume from the server it’s attached to. You may attach it to a server again at a later time. @@ -432,7 +431,7 @@ def detach(self, volume: Volume | BoundVolume) -> BoundAction: url=f"/volumes/{volume.id}/actions/detach", method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def change_protection( self, @@ -455,4 +454,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index ed207400..fcd3d12c 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -18,10 +18,6 @@ class TestBoundAction: @pytest.fixture() def bound_running_action(self, client: Client): - # Speed up tests that run `wait_until_finished` - client._poll_interval_func = lambda _: 0.0 - client._poll_max_retries = 3 - return BoundAction( client=client.actions, data=dict(id=14, status=Action.STATUS_RUNNING), @@ -89,7 +85,7 @@ def test_get_by_id( request_mock.return_value = generic_action action = actions_client.get_by_id(1) request_mock.assert_called_with(url="/resource/actions/1", method="GET") - assert action._client == actions_client._client.actions + assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -118,11 +114,11 @@ def test_get_list( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -148,11 +144,11 @@ def test_get_all( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -171,7 +167,7 @@ def test_get_by_id( request_mock.return_value = generic_action action = actions_client.get_by_id(1) request_mock.assert_called_with(url="/actions/1", method="GET") - assert action._client == actions_client._client.actions + assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -199,11 +195,11 @@ def test_get_list( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -228,10 +224,10 @@ def test_get_all( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index d8ebaf0d..1ef152fa 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -358,7 +358,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/certificates/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == certificates_client._client.actions + assert action._client == certificates_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -382,7 +382,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions + assert actions[0]._client == certificates_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -403,6 +403,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions + assert actions[0]._client == certificates_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 30a55152..ce6adbe6 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -9,6 +9,12 @@ from hcloud import Client +@pytest.fixture(autouse=True, scope="session") +def patch_package_version(): + with mock.patch("hcloud._client.__version__", "0.0.0"): + yield + + @pytest.fixture() def request_mock() -> mock.MagicMock: return mock.MagicMock() @@ -16,9 +22,13 @@ def request_mock() -> mock.MagicMock: @pytest.fixture() def client(request_mock) -> Client: - c = Client(token="TOKEN") - c.request = request_mock - c._request_hetzner = request_mock + c = Client( + token="TOKEN", + # Speed up tests that use `_poll_interval_func` + poll_interval=0.0, + poll_max_retries=3, + ) + c._client.request = request_mock return c diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index e3bdf5e4..a2f91375 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -361,7 +361,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" @@ -556,7 +556,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/firewalls/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == firewalls_client._client.actions + assert action._client == firewalls_client._parent.actions assert action.id == 13 assert action.command == "set_firewall_rules" @@ -580,7 +580,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" @@ -601,6 +601,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index e26da382..032d4914 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -366,7 +366,7 @@ def test_get_actions( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" @@ -511,7 +511,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/floating_ips/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == floating_ips_client._client.actions + assert action._client == floating_ips_client._parent.actions assert action.id == 13 assert action.command == "assign_floating_ip" @@ -535,7 +535,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" @@ -556,6 +556,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 8e6add3a..14ea5334 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -311,7 +311,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -391,7 +391,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/images/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == images_client._client.actions + assert action._client == images_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -415,7 +415,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -436,6 +436,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 158493c2..a2bc4388 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -630,7 +630,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/load_balancers/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == load_balancers_client._client.actions + assert action._client == load_balancers_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -654,7 +654,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions + assert actions[0]._client == load_balancers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -675,6 +675,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions + assert actions[0]._client == load_balancers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index a80f87b1..ae98f485 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -521,7 +521,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" @@ -743,7 +743,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/networks/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == networks_client._client.actions + assert action._client == networks_client._parent.actions assert action.id == 13 assert action.command == "add_subnet" @@ -767,7 +767,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" @@ -788,6 +788,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index d3809e48..33b74669 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -390,7 +390,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/primary_ips/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == primary_ips_client._client.actions + assert action._client == primary_ips_client._parent.actions assert action.id == 13 assert action.command == "assign_primary_ip" @@ -414,7 +414,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions + assert actions[0]._client == primary_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_primary_ip" @@ -435,6 +435,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions + assert actions[0]._client == primary_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_primary_ip" diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 7f2cd2c1..cb785b0c 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -66,7 +66,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.datacenter, BoundDatacenter) assert ( - bound_server.datacenter._client == bound_server._client._client.datacenters + bound_server.datacenter._client == bound_server._client._parent.datacenters ) assert bound_server.datacenter.id == 1 assert bound_server.datacenter.complete is True @@ -74,30 +74,30 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.server_type, BoundServerType) assert ( bound_server.server_type._client - == bound_server._client._client.server_types + == bound_server._client._parent.server_types ) assert bound_server.server_type.id == 1 assert bound_server.server_type.complete is True assert len(bound_server.volumes) == 2 assert isinstance(bound_server.volumes[0], BoundVolume) - assert bound_server.volumes[0]._client == bound_server._client._client.volumes + assert bound_server.volumes[0]._client == bound_server._client._parent.volumes assert bound_server.volumes[0].id == 1 assert bound_server.volumes[0].complete is False assert isinstance(bound_server.volumes[1], BoundVolume) - assert bound_server.volumes[1]._client == bound_server._client._client.volumes + assert bound_server.volumes[1]._client == bound_server._client._parent.volumes assert bound_server.volumes[1].id == 2 assert bound_server.volumes[1].complete is False assert isinstance(bound_server.image, BoundImage) - assert bound_server.image._client == bound_server._client._client.images + assert bound_server.image._client == bound_server._client._parent.images assert bound_server.image.id == 4711 assert bound_server.image.name == "ubuntu-20.04" assert bound_server.image.complete is True assert isinstance(bound_server.iso, BoundIso) - assert bound_server.iso._client == bound_server._client._client.isos + assert bound_server.iso._client == bound_server._client._parent.isos assert bound_server.iso.id == 4711 assert bound_server.iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" assert bound_server.iso.complete is True @@ -106,7 +106,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.private_net[0], PrivateNet) assert ( bound_server.private_net[0].network._client - == bound_server._client._client.networks + == bound_server._client._parent.networks ) assert bound_server.private_net[0].ip == "10.1.1.5" assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" @@ -116,7 +116,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.placement_group, BoundPlacementGroup) assert ( bound_server.placement_group._client - == bound_server._client._client.placement_groups + == bound_server._client._parent.placement_groups ) assert bound_server.placement_group.id == 897 assert bound_server.placement_group.name == "my Placement Group" @@ -774,7 +774,7 @@ def test_create_with_datacenter( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -811,7 +811,7 @@ def test_create_with_location( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -854,7 +854,7 @@ def test_create_with_volumes( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -899,7 +899,7 @@ def test_create_with_networks( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -944,7 +944,7 @@ def test_create_with_firewalls( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -990,7 +990,7 @@ def test_create_with_placement_group( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -1018,7 +1018,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" @@ -1564,7 +1564,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/servers/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == servers_client._client.actions + assert action._client == servers_client._parent.actions assert action.id == 13 assert action.command == "start_server" @@ -1588,7 +1588,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" @@ -1609,6 +1609,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 4731336c..41094a88 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,7 +1,9 @@ from __future__ import annotations -import json -from unittest.mock import MagicMock +from http import HTTPStatus +from json import dumps +from typing import Any +from unittest import mock import pytest import requests @@ -12,201 +14,86 @@ constant_backoff_function, exponential_backoff_function, ) +from hcloud._client import ClientBase, _build_user_agent -class TestHetznerClient: +def test_exponential_backoff_function(): + backoff = exponential_backoff_function( + base=1.0, + multiplier=2, + cap=60.0, + ) + max_retries = 5 + + results = [backoff(i) for i in range(max_retries)] + assert sum(results) == 31.0 + assert results == [1.0, 2.0, 4.0, 8.0, 16.0] + + +def test_constant_backoff_function(): + backoff = constant_backoff_function(interval=1.0) + max_retries = 5 + + for i in range(max_retries): + assert backoff(i) == 1.0 + + +def test_build_user_agent(): + assert _build_user_agent(None, None) == "hcloud-python/0.0.0" + assert _build_user_agent("my-app", None) == "my-app hcloud-python/0.0.0" + assert _build_user_agent("my-app", "1.0.0") == "my-app/1.0.0 hcloud-python/0.0.0" + assert _build_user_agent(None, "1.0.0") == "hcloud-python/0.0.0" + + +class TestClient: @pytest.fixture() def client(self): - Client._version = "0.0.0" - client = Client(token="project_token") + return Client(token="TOKEN") - client._requests_session = MagicMock() - return client + def test_request(self, client: Client): + client._client.request = mock.MagicMock() + client.request(method="GET", url="/path") + client._client.request.assert_called_once_with("GET", "/path") - @pytest.fixture() - def response(self): - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - return response - @pytest.fixture() - def fail_response(self, response): - response.status_code = 422 - error = { - "code": "invalid_input", - "message": "invalid input in field 'broken_field': is too long", - "details": { - "fields": [{"name": "broken_field", "messages": ["is too long"]}] - }, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response +def make_response( + status: HTTPStatus, + *, + json: Any | None = None, + text: str | None = None, +) -> requests.Response: + response = requests.Response() + response.status_code = status.value + response.reason = status.phrase + if json is not None: + response.headers["Content-type"] = "application/json" + response._content = dumps(json).encode("utf-8") + elif text is not None: + response.headers["Content-type"] = "text/plain" + response._content = text.encode("utf-8") + return response + + +class TestBaseClient: @pytest.fixture() - def rate_limit_response(self, response): - response.status_code = 422 - error = { - "code": "rate_limit_exceeded", - "message": "limit of 10 requests per hour reached", - "details": {}, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response - - def test__get_user_agent(self, client): - user_agent = client._get_user_agent() - assert user_agent == "hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name(self, client): - client = Client(token="project_token", application_name="my-app") - user_agent = client._get_user_agent() - assert user_agent == "my-app hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name_and_version(self, client): - client = Client( - token="project_token", - application_name="my-app", - application_version="1.0.0", + def client(self): + client = ClientBase( + token="TOKEN", + endpoint="https://api.hetzner.cloud/v1", ) - user_agent = client._get_user_agent() - assert user_agent == "my-app/1.0.0 hcloud-python/0.0.0" + client._session = mock.MagicMock() + return client - def test__get_headers(self, client): - headers = client._get_headers() - assert headers == { + def test_init(self, client: ClientBase): + assert client._user_agent == "hcloud-python/0.0.0" + assert client._headers == { "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", } - - def test_request_ok(self, client, response): - client._requests_session.request.return_value = response - response = client.request( - "POST", "/servers", params={"argument": "value"}, timeout=2 - ) - client._requests_session.request.assert_called_once_with( - method="POST", - url="https://api.hetzner.cloud/v1/servers", - headers={ - "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", - }, - params={"argument": "value"}, - timeout=2, - ) - assert response == {"result": "data"} - - def test_request_fails(self, client, fail_response): - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == "invalid_input" - assert error.message == "invalid input in field 'broken_field': is too long" - assert error.details["fields"][0]["name"] == "broken_field" - - def test_request_fails_correlation_id(self, client, response): - response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" - response.status_code = 422 - response._content = json.dumps( - { - "error": { - "code": "service_error", - "message": "Something crashed", - } - } - ).encode("utf-8") - - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == "service_error" - assert error.message == "Something crashed" - assert error.details is None - assert error.correlation_id == "67ed842dc8bc8673" - assert str(error) == "Something crashed (service_error, 67ed842dc8bc8673)" - - def test_request_500(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "Internal Server Error" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "Internal Server Error" - - def test_request_broken_json_200(self, client, response): - content = b"{'key': 'value'" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 200 - assert error.message == "OK" - assert error.details["content"] == content - - def test_request_empty_content_200(self, client, response): - content = "" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - response = client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - assert response == {} - - def test_request_500_empty_content(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "" - assert str(error) == "Internal Server Error (500)" - - def test_request_limit(self, client, rate_limit_response): - client._retry_interval = constant_backoff_function(0.0) - client._requests_session.request.return_value = rate_limit_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert client._requests_session.request.call_count == 6 - assert error.code == "rate_limit_exceeded" - assert error.message == "limit of 10 requests per hour reached" - - def test_request_limit_then_success(self, client, rate_limit_response): - client._retry_interval = constant_backoff_function(0.0) - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - client._requests_session.request.side_effect = [rate_limit_response, response] - - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - assert client._requests_session.request.call_count == 2 + assert client._poll_interval_func(1) == 1.0 + assert client._retry_interval_func(1) == pytest.approx(1.5, rel=0.5) # Jitter @pytest.mark.parametrize( ("exception", "expected"), @@ -241,26 +128,200 @@ def test_request_limit_then_success(self, client, rate_limit_response): ), ], ) - def test_retry_policy(self, client, exception, expected): + def test_retry_policy( + self, + client: ClientBase, + exception: APIException, + expected: bool, + ): assert client._retry_policy(exception) == expected + def test_request_200(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ) -def test_constant_backoff_function(): - backoff = constant_backoff_function(interval=1.0) - max_retries = 5 + result = client.request( + method="POST", + url="/path", + params={"argument": "value"}, + timeout=2, + ) - for i in range(max_retries): - assert backoff(i) == 1.0 + client._session.request.assert_called_once_with( + method="POST", + url="https://api.hetzner.cloud/v1/path", + headers={ + "User-Agent": "hcloud-python/0.0.0", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", + }, + params={"argument": "value"}, + timeout=2, + ) + assert result == {"result": "data"} + def test_request_200_empty_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="", + ) -def test_exponential_backoff_function(): - backoff = exponential_backoff_function( - base=1.0, - multiplier=2, - cap=60.0, - ) - max_retries = 5 + result = client.request(method="POST", url="/path") + assert result == {} - results = [backoff(i) for i in range(max_retries)] - assert sum(results) == 31.0 - assert results == [1.0, 2.0, 4.0, 8.0, 16.0] + def test_request_fail_200_invalid_json(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="{'key': 'value'", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 200 + assert exc.value.message == "OK" + assert exc.value.details["content"] == b"{'key': 'value'" + + def test_request_fail_422(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "invalid_input", + "message": "invalid input in field 'broken_field': is too long", + "details": { + "fields": [ + {"name": "broken_field", "messages": ["is too long"]} + ] + }, + } + }, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "invalid_input" + assert exc.value.message == "invalid input in field 'broken_field': is too long" + assert exc.value.details["fields"][0]["name"] == "broken_field" + + def test_request_fail_422_correlation_id(self, client: ClientBase): + response = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "service_error", + "message": "Something crashed", + } + }, + ) + response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" + client._session.request.return_value = response + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "service_error" + assert exc.value.message == "Something crashed" + assert exc.value.details is None + assert exc.value.correlation_id == "67ed842dc8bc8673" + assert str(exc.value) == "Something crashed (service_error, 67ed842dc8bc8673)" + + def test_request_fail_500(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + text="Internal Server Error", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] == b"Internal Server Error" + + def test_request_fail_500_no_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] is None + assert str(exc.value) == "Internal Server Error (500)" + + def test_request_fail_419(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.return_value = make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ) + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert client._session.request.call_count == 6 + assert exc.value.code == "rate_limit_exceeded" + assert exc.value.message == "limit of 3600 requests per hour reached" + + def test_request_fail_419_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} + + def test_request_fail_timeout(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + client._session.request.side_effect = requests.exceptions.Timeout("timeout") + + with pytest.raises(requests.exceptions.Timeout) as exc: + client.request(method="GET", url="/path") + + assert str(exc.value) == "timeout" + assert client._session.request.call_count == 6 + + def test_request_fail_timeout_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + requests.exceptions.Timeout("timeout"), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 3f5865ab..1f5f7534 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -378,7 +378,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" @@ -510,7 +510,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/volumes/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == volumes_client._client.actions + assert action._client == volumes_client._parent.actions assert action.id == 13 assert action.command == "attach_volume" @@ -534,7 +534,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" @@ -555,6 +555,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" From 74d9d03b07756b8966aade2aaf7a9f647a3b0918 Mon Sep 17 00:00:00 2001 From: jo Date: Fri, 15 Aug 2025 10:34:33 +0200 Subject: [PATCH 2/2] refactor: fixes after base client split --- hcloud/_client.py | 9 +++++++++ hcloud/storage_box_types/client.py | 8 +++++--- tests/unit/conftest.py | 1 + 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/hcloud/_client.py b/hcloud/_client.py index 5e2b400d..484669db 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -167,6 +167,15 @@ def __init__( poll_max_retries=poll_max_retries, timeout=timeout, ) + self._client_hetzner = ClientBase( + token=token, + endpoint=api_endpoint_hetzner, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) self.datacenters = DatacentersClient(self) """DatacentersClient Instance diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index a1435b41..9e8ce552 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -27,7 +27,9 @@ class StorageBoxTypesClient(ResourceClientBase): See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. """ - _client: Client + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner def get_by_id(self, id: int) -> BoundStorageBoxType: """ @@ -37,7 +39,7 @@ def get_by_id(self, id: int) -> BoundStorageBoxType: :param id: ID of the Storage Box Type. """ - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="GET", url=f"/storage_box_types/{id}", ) @@ -76,7 +78,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="GET", url="/storage_box_types", params=params, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ce6adbe6..83270c01 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -29,6 +29,7 @@ def client(request_mock) -> Client: poll_max_retries=3, ) c._client.request = request_mock + c._client_hetzner.request = request_mock return c