From 3c460882cdfc0fa5f2192506645d8a4938b241c8 Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 7 Aug 2025 18:08:05 +0200 Subject: [PATCH 1/2] feat: support Storage Box Types --- docs/api.clients.storage_box_types.rst | 11 ++ hcloud/_client.py | 7 + hcloud/storage_box_types/__init__.py | 15 ++ hcloud/storage_box_types/client.py | 105 +++++++++++++ hcloud/storage_box_types/domain.py | 49 ++++++ tests/unit/storage_box_types/__init__.py | 0 tests/unit/storage_box_types/conftest.py | 50 ++++++ tests/unit/storage_box_types/test_client.py | 165 ++++++++++++++++++++ 8 files changed, 402 insertions(+) create mode 100644 docs/api.clients.storage_box_types.rst create mode 100644 hcloud/storage_box_types/__init__.py create mode 100644 hcloud/storage_box_types/client.py create mode 100644 hcloud/storage_box_types/domain.py create mode 100644 tests/unit/storage_box_types/__init__.py create mode 100644 tests/unit/storage_box_types/conftest.py create mode 100644 tests/unit/storage_box_types/test_client.py diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst new file mode 100644 index 00000000..4a147839 --- /dev/null +++ b/docs/api.clients.storage_box_types.rst @@ -0,0 +1,11 @@ +StorageBoxTypesClient +================== + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesClient + :members: + +.. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType + :members: + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxType + :members: diff --git a/hcloud/_client.py b/hcloud/_client.py index 2992ac04..b44891e6 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -25,6 +25,7 @@ from .server_types import ServerTypesClient from .servers import ServersClient from .ssh_keys import SSHKeysClient +from .storage_box_types import StorageBoxTypesClient from .volumes import VolumesClient @@ -250,6 +251,12 @@ def __init__( :type: :class:`PlacementGroupsClient ` """ + self.storage_box_types = StorageBoxTypesClient(self) + """StorageBoxTypesClient Instance + + :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) diff --git a/hcloud/storage_box_types/__init__.py b/hcloud/storage_box_types/__init__.py new file mode 100644 index 00000000..4e6818c4 --- /dev/null +++ b/hcloud/storage_box_types/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBoxType, + StorageBoxTypesClient, + StorageBoxTypesPageResult, +) +from .domain import StorageBoxType + +__all__ = [ + "BoundStorageBoxType", + "StorageBoxTypesClient", + "StorageBoxTypesPageResult", + "StorageBoxType", +] diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py new file mode 100644 index 00000000..1e8d8154 --- /dev/null +++ b/hcloud/storage_box_types/client.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..core import BoundModelBase, ClientEntityBase, Meta +from .domain import StorageBoxType + +if TYPE_CHECKING: + from .._client import Client + + +class BoundStorageBoxType(BoundModelBase, StorageBoxType): + _client: StorageBoxTypesClient + + model = StorageBoxType + + +class StorageBoxTypesPageResult(NamedTuple): + storage_box_types: list[BoundStorageBoxType] + meta: Meta + + +class StorageBoxTypesClient(ClientEntityBase): + """ + A client for the Storage Box Types API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + _client: Client + + def get_by_id(self, id: int) -> BoundStorageBoxType: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-get-a-storage-box-type + + :param id: ID of the Storage Box Type. + """ + response = self._client._request_hetzner( # pylint: disable=protected-access + method="GET", + url=f"/storage_box_types/{id}", + ) + return BoundStorageBoxType(self, response["storage_box_type"]) + + def get_by_name(self, name: str) -> BoundStorageBoxType | None: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + """ + return self._get_first_by(name=name) + + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxTypesPageResult: + """ + Returns a list of Storage Box Types for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client._request_hetzner( # pylint: disable=protected-access + method="GET", + url="/storage_box_types", + params=params, + ) + return StorageBoxTypesPageResult( + storage_box_types=[ + BoundStorageBoxType(self, o) for o in response["storage_box_types"] + ], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + name: str | None = None, + ) -> list[BoundStorageBoxType]: + """ + Returns all Storage Box Types. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + """ + return self._iter_pages( + self.get_list, + name=name, + ) diff --git a/hcloud/storage_box_types/domain.py b/hcloud/storage_box_types/domain.py new file mode 100644 index 00000000..b807ce27 --- /dev/null +++ b/hcloud/storage_box_types/domain.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + + +class StorageBoxType(BaseDomain, DomainIdentityMixin): + """ + Storage Box Type Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "snapshot_limit", + "automatic_snapshot_limit", + "subaccounts_limit", + "size", + "deprecation", + "prices", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + snapshot_limit: int | None = None, + automatic_snapshot_limit: int | None = None, + subaccounts_limit: int | None = None, + size: int | None = None, + prices: list[dict] | None = None, + deprecation: dict | None = None, + ): + self.id = id + self.name = name + self.description = description + self.snapshot_limit = snapshot_limit + self.automatic_snapshot_limit = automatic_snapshot_limit + self.subaccounts_limit = subaccounts_limit + self.size = size + self.prices = prices + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) diff --git a/tests/unit/storage_box_types/__init__.py b/tests/unit/storage_box_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_box_types/conftest.py b/tests/unit/storage_box_types/conftest.py new file mode 100644 index 00000000..e2717142 --- /dev/null +++ b/tests/unit/storage_box_types/conftest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box_type1(): + return { + "id": 42, + "name": "bx11", + "description": "BX11", + "snapshot_limit": 10, + "automatic_snapshot_limit": 10, + "subaccounts_limit": 100, + "size": 1099511627776, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ], + "deprecation": { + "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00+00:00", + }, + } + + +@pytest.fixture() +def storage_box_type2(): + return { + "id": 43, + "name": "bx21", + "description": "BX21", + "snapshot_limit": 20, + "automatic_snapshot_limit": 20, + "subaccounts_limit": 100, + "size": 5497558138880, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"net": "1.0000", "gross": "1.1900"}, + "price_monthly": {"net": "1.0000", "gross": "1.1900"}, + "setup_fee": {"net": "1.0000", "gross": "1.1900"}, + } + ], + "deprecation": None, + } diff --git a/tests/unit/storage_box_types/test_client.py b/tests/unit/storage_box_types/test_client.py new file mode 100644 index 00000000..b4f1c971 --- /dev/null +++ b/tests/unit/storage_box_types/test_client.py @@ -0,0 +1,165 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud.storage_box_types import ( + BoundStorageBoxType, + StorageBoxTypesClient, +) + + +def assert_bound_model( + o: BoundStorageBoxType, + client: StorageBoxTypesClient, +): + assert isinstance(o, BoundStorageBoxType) + assert o._client is client + assert o.id == 42 + assert o.name == "bx11" + + +@pytest.fixture(name="client") +def client_fixture() -> StorageBoxTypesClient: + return StorageBoxTypesClient(client=mock.MagicMock()) + + +class TestClient: + def test_get_by_id( + self, + client: StorageBoxTypesClient, + storage_box_type1, + ): + client._client._request_hetzner.return_value = { + "storage_box_type": storage_box_type1 + } + + result = client.get_by_id(42) + + client._client._request_hetzner.assert_called_with( + method="GET", + url="/storage_box_types/42", + ) + + assert_bound_model(result, client) + assert result.description == "BX11" + assert result.snapshot_limit == 10 + assert result.automatic_snapshot_limit == 10 + assert result.subaccounts_limit == 100 + assert result.size == 1099511627776 + assert result.prices == [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ] + assert result.deprecation.announced == isoparse("2023-06-01T00:00:00+00:00") + assert result.deprecation.unavailable_after == isoparse( + "2023-09-01T00:00:00+00:00" + ) + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11", "page": 1, "per_page": 10}, + {}, + ], + ) + def test_get_list( + self, + client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + client._client._request_hetzner.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = client.get_list(**params) + + client._client._request_hetzner.assert_called_with( + url="/storage_box_types", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_box_types) == 2 + + result1 = result.storage_box_types[0] + result2 = result.storage_box_types[1] + + assert result1._client is client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is client + assert result2.id == 43 + assert result2.name == "bx21" + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11"}, + {}, + ], + ) + def test_get_all( + self, + client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + client._client._request_hetzner.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = client.get_all(**params) + + client._client._request_hetzner.assert_called_with( + url="/storage_box_types", + method="GET", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is client + assert result2.id == 43 + assert result2.name == "bx21" + + def test_get_by_name( + self, + client: StorageBoxTypesClient, + storage_box_type1, + ): + client._client._request_hetzner.return_value = { + "storage_box_types": [storage_box_type1] + } + + result = client.get_by_name("bx11") + + params = {"name": "bx11"} + + client._client._request_hetzner.assert_called_with( + method="GET", + url="/storage_box_types", + params=params, + ) + + assert_bound_model(result, client) From 06ad6c7c513d01da7cbbb22f6da499a2702c1a1c Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 7 Aug 2025 18:32:02 +0200 Subject: [PATCH 2/2] docs: fix rst --- docs/api.clients.storage_box_types.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst index 4a147839..e0b9ef14 100644 --- a/docs/api.clients.storage_box_types.rst +++ b/docs/api.clients.storage_box_types.rst @@ -1,5 +1,5 @@ StorageBoxTypesClient -================== +===================== .. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesClient :members: