From 246d2427fed193cddf760646e4a776e899565abf Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 11 Aug 2025 09:54:20 +0200 Subject: [PATCH 1/3] feat: support Storage Box CRUD Add support for the Storage Box CRUD operations. - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-create-a-storage-box - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-storage-box - https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-content-of-storage-box --- docs/api.clients.storage_boxes.rst | 29 +++ hcloud/_client.py | 7 + hcloud/storage_boxes/__init__.py | 29 +++ hcloud/storage_boxes/client.py | 278 +++++++++++++++++++++ hcloud/storage_boxes/domain.py | 229 ++++++++++++++++++ tests/unit/storage_boxes/__init__.py | 0 tests/unit/storage_boxes/conftest.py | 103 ++++++++ tests/unit/storage_boxes/test_client.py | 307 ++++++++++++++++++++++++ tests/unit/storage_boxes/test_domain.py | 15 ++ 9 files changed, 997 insertions(+) create mode 100644 docs/api.clients.storage_boxes.rst create mode 100644 hcloud/storage_boxes/__init__.py create mode 100644 hcloud/storage_boxes/client.py create mode 100644 hcloud/storage_boxes/domain.py create mode 100644 tests/unit/storage_boxes/__init__.py create mode 100644 tests/unit/storage_boxes/conftest.py create mode 100644 tests/unit/storage_boxes/test_client.py create mode 100644 tests/unit/storage_boxes/test_domain.py diff --git a/docs/api.clients.storage_boxes.rst b/docs/api.clients.storage_boxes.rst new file mode 100644 index 00000000..b3710059 --- /dev/null +++ b/docs/api.clients.storage_boxes.rst @@ -0,0 +1,29 @@ +StorageBoxesClient +===================== + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesClient + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotPlan + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxStats + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxAccessSettings + :members: + +.. autoclass:: hcloud.storage_boxes.client.CreateStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.client.DeleteStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxFoldersResponse + :members: diff --git a/hcloud/_client.py b/hcloud/_client.py index 484669db..ee375a7f 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -26,6 +26,7 @@ from .servers import ServersClient from .ssh_keys import SSHKeysClient from .storage_box_types import StorageBoxTypesClient +from .storage_boxes import StorageBoxesClient from .volumes import VolumesClient @@ -273,6 +274,12 @@ def __init__( :type: :class:`StorageBoxTypesClient ` """ + self.storage_boxes = StorageBoxesClient(self) + """StorageBoxesClient Instance + + :type: :class:`StorageBoxesClient ` + """ + def request( # type: ignore[no-untyped-def] self, method: str, diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py new file mode 100644 index 00000000..3c241c55 --- /dev/null +++ b/hcloud/storage_boxes/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBox, + StorageBoxesClient, + StorageBoxesPageResult, +) +from .domain import ( + CreateStorageBoxResponse, + DeleteStorageBoxResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshotPlan, + StorageBoxStats, +) + +__all__ = [ + "BoundStorageBox", + "StorageBoxesClient", + "StorageBoxesPageResult", + "StorageBox", + "StorageBoxSnapshotPlan", + "StorageBoxStats", + "StorageBoxAccessSettings", + "CreateStorageBoxResponse", + "DeleteStorageBoxResponse", + "StorageBoxFoldersResponse", +] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py new file mode 100644 index 00000000..40ac290d --- /dev/null +++ b/hcloud/storage_boxes/client.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import BoundAction +from ..core import BoundModelBase, ClientEntityBase, Meta +from ..locations import BoundLocation, Location +from ..storage_box_types import BoundStorageBoxType, StorageBoxType +from .domain import ( + CreateStorageBoxResponse, + DeleteStorageBoxResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshotPlan, + StorageBoxStats, +) + +if TYPE_CHECKING: + from .._client import Client + + +class BoundStorageBox(BoundModelBase, StorageBox): + _client: StorageBoxesClient + + model = StorageBox + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box_type") + if raw is not None: + data["storage_box_type"] = BoundStorageBoxType( + client._client.storage_box_types, raw + ) + + raw = data.get("location") + if raw is not None: + data["location"] = BoundLocation(client._client.locations, raw) + + raw = data.get("snapshot_plan") + if raw is not None: + data["snapshot_plan"] = StorageBoxSnapshotPlan.from_dict(raw) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxAccessSettings.from_dict(raw) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxStats.from_dict(raw) + + super().__init__(client, data, complete) + + # TODO: implement bound methods + + +class StorageBoxesPageResult(NamedTuple): + storage_boxes: list[BoundStorageBox] + meta: Meta + + +class StorageBoxesClient(ClientEntityBase): + """ + A client for the Storage Boxes API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + _client: Client + + def get_by_id(self, id: int) -> BoundStorageBox: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box + + :param id: ID of the Storage Box. + """ + response = self._client._request_hetzner( # pylint: disable=protected-access + method="GET", + url=f"/storage_boxes/{id}", + ) + return BoundStorageBox(self, response["storage_box"]) + + def get_by_name(self, name: str) -> BoundStorageBox | None: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + """ + return self._get_first_by(name=name) + + def get_list( + self, + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxesPageResult: + """ + Returns a list of Storage Boxes for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :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 label_selector is not None: + params["label_selector"] = label_selector + 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_boxes", + params=params, + ) + return StorageBoxesPageResult( + storage_boxes=[BoundStorageBox(self, o) for o in response["storage_boxes"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + ) -> list[BoundStorageBox]: + """ + Returns all Storage Boxes. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + """ + return self._iter_pages( + self.get_list, + name=name, + label_selector=label_selector, + ) + + def create( + self, + *, + name: str, + password: str, + location: BoundLocation | Location, + storage_box_type: BoundStorageBoxType | StorageBoxType, + ssh_keys: list[str] | None = None, + access_settings: StorageBoxAccessSettings | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxResponse: + """ + Creates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-create-a-storage-box + + :param name: Name of the Storage Box. + :param password: Password of the Storage Box. + :param location: Location of the Storage Box. + :param storage_box_type: Type of the Storage Box. + :param ssh_keys: SSH public keys of the Storage Box. + :param access_settings: Access settings of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + data: dict[str, Any] = { + "name": name, + "password": password, + "location": location.name, # TODO: ID or name ? + "storage_box_type": storage_box_type.id_or_name, + } + if ssh_keys is not None: + data["ssh_keys"] = ssh_keys + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if labels is not None: + data["labels"] = labels + + response = self._client._request_hetzner( # pylint: disable=protected-access + method="POST", + url="/storage_boxes", + json=data, + ) + + return CreateStorageBoxResponse( + storage_box=BoundStorageBox(self, response["storage_box"]), + action=BoundAction(self._client.actions, response["action"]), + ) + + def update( + self, + storage_box: BoundStorageBox | StorageBox, + *, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBox: + """ + Updates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box + + :param storage_box: Storage Box to update. + :param name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if labels is not None: + data["labels"] = labels + + response = self._client._request_hetzner( # pylint: disable=protected-access + method="PUT", + url=f"/storage_boxes/{storage_box.id}", + json=data, + ) + + return BoundStorageBox(self, response["storage_box"]) + + def delete( + self, + storage_box: BoundStorageBox | StorageBox, + ) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-storage-box + + :param storage_box: Storage Box to delete. + """ + response = self._client._request_hetzner( # pylint: disable=protected-access + method="DELETE", + url=f"/storage_boxes/{storage_box.id}", + ) + + return DeleteStorageBoxResponse( + action=BoundAction(self._client.actions, response["action"]) + ) + + def get_folders( + self, + storage_box: BoundStorageBox | StorageBox, + path: str | None = None, + ) -> StorageBoxFoldersResponse: + """ + Lists the (sub)folders contained in a Storage Box. + + Files are not part of the response. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-content-of-storage-box + + :param storage_box: Storage Box to list the folders from. + :param path: Relative path to list the folders from. + """ + params: dict[str, Any] = {} + if path is not None: + params["path"] = path + + response = self._client._request_hetzner( # pylint: disable=protected-access + method="GET", + url=f"/storage_boxes/{storage_box.id}/folders", + params=params, + ) + + return StorageBoxFoldersResponse(folders=response["folders"]) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py new file mode 100644 index 00000000..d324b6f4 --- /dev/null +++ b/hcloud/storage_boxes/domain.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from dateutil.parser import isoparse + +from ..actions import BoundAction +from ..core import BaseDomain, DomainIdentityMixin +from ..locations import BoundLocation, Location +from ..storage_box_types import BoundStorageBoxType, StorageBoxType + +if TYPE_CHECKING: + from .client import BoundStorageBox + +StorageBoxStatus = Literal[ + "active", + "initializing", + "locked", +] + + +class StorageBox(BaseDomain, DomainIdentityMixin): + """ + Storage Box Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + STATUS_ACTIVE = "active" + STATUS_INITIALIZING = "initializing" + STATUS_LOCKED = "locked" + + __api_properties__ = ( + "id", + "name", + "storage_box_type", + "location", + "system", + "server", + "username", + "labels", + "protection", + "snapshot_plan", + "access_settings", + "stats", + "status", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + storage_box_type: BoundStorageBoxType | StorageBoxType | None = None, + location: BoundLocation | Location | None = None, + system: str | None = None, + server: str | None = None, + username: str | None = None, + labels: dict[str, str] | None = None, + protection: dict[str, bool] | None = None, + snapshot_plan: StorageBoxSnapshotPlan | None = None, + access_settings: StorageBoxAccessSettings | None = None, + stats: StorageBoxStats | None = None, + status: StorageBoxStatus | None = None, + created: str | None = None, + ): + self.id = id + self.name = name + self.storage_box_type = storage_box_type + self.location = location + self.system = system + self.server = server + self.username = username + self.labels = labels + self.protection = protection + self.snapshot_plan = snapshot_plan + self.access_settings = access_settings + self.stats = stats + self.status = status + self.created = isoparse(created) if created else None + + +class StorageBoxAccessSettings(BaseDomain): + """ + Storage Box Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "zfs_enabled", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + zfs_enabled: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.zfs_enabled = zfs_enabled + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.zfs_enabled is not None: + payload["zfs_enabled"] = self.zfs_enabled + return payload + + +class StorageBoxStats(BaseDomain): + """ + Storage Box Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_data", + "size_snapshots", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int | None = None, + size_data: int | None = None, + size_snapshots: int | None = None, + ): + self.size = size + self.size_data = size_data + self.size_snapshots = size_snapshots + + +class StorageBoxSnapshotPlan(BaseDomain): + """ + Storage Box Snapshot Plan Domain. + """ + + __api_properties__ = ( + "max_snapshots", + "minute", + "hour", + "day_of_week", + "day_of_month", + ) + __slots__ = __api_properties__ + + def __init__( + self, + max_snapshots: int | None = None, + minute: int | None = None, + hour: int | None = None, + day_of_week: int | None = None, + day_of_month: int | None = None, + ): + self.max_snapshots = max_snapshots + self.minute = minute + self.hour = hour + self.day_of_week = day_of_week + self.day_of_month = day_of_month + + +class CreateStorageBoxResponse(BaseDomain): + """ + Create Storage Box Response Domain. + """ + + __api_properties__ = ( + "storage_box", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + storage_box: BoundStorageBox, + action: BoundAction, + ): + self.storage_box = storage_box + self.action = action + + +class DeleteStorageBoxResponse(BaseDomain): + """ + Delete Storage Box Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class StorageBoxFoldersResponse(BaseDomain): + """ + Storage Box Folders Response Domain. + """ + + __api_properties__ = ("folders",) + __slots__ = __api_properties__ + + def __init__( + self, + folders: list[str], + ): + self.folders = folders diff --git a/tests/unit/storage_boxes/__init__.py b/tests/unit/storage_boxes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py new file mode 100644 index 00000000..834ca7e9 --- /dev/null +++ b/tests/unit/storage_boxes/conftest.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box1(): + return { + "id": 42, + "name": "storage-box1", + "created": "2025-01-30T23:55:00+00:00", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 42, + "name": "bx11", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "reachable_externally": False, + "samba_enabled": False, + "ssh_enabled": False, + "webdav_enabled": False, + "zfs_enabled": False, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": { + "key": "value", + }, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def storage_box2(): + return { + "id": 43, + "name": "storage-box2", + "created": "2022-09-30T10:30:09.000Z", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 1334, + "name": "bx21", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "webdav_enabled": False, + "zfs_enabled": False, + "samba_enabled": False, + "ssh_enabled": True, + "reachable_externally": True, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": {}, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def action_running1(): + return { + "id": 22, + "command": "command", + "status": "running", + "progress": 0, + "started": "2025-01-30T23:55:00+00:00", + "finished": None, + "resources": [{"id": 42, "type": "resource"}], + "error": None, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py new file mode 100644 index 00000000..be24782a --- /dev/null +++ b/tests/unit/storage_boxes/test_client.py @@ -0,0 +1,307 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.actions import ActionsClient, BoundAction +from hcloud.locations import Location +from hcloud.storage_box_types import StorageBoxType +from hcloud.storage_boxes import ( + BoundStorageBox, + StorageBox, + StorageBoxesClient, +) +from hcloud.storage_boxes.domain import StorageBoxAccessSettings + + +def assert_bound_model( + o: BoundStorageBox, + client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBox) + assert o._client is client + assert o.id == 42 + assert o.name == "storage-box1" + + +def assert_bound_action( + o: BoundAction, + client: ActionsClient, +): + assert isinstance(o, BoundAction) + assert o._client is client + assert o.id == 22 + + +@pytest.fixture(name="request_mock") +def request_mock_fixture(): + return mock.MagicMock() + + +@pytest.fixture(name="client") +def client_fixture(request_mock) -> StorageBoxesClient: + client = Client("") + client._request_hetzner = request_mock + return StorageBoxesClient(client=client) + + +class TestClient: + def test_get_by_id( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_box": storage_box1} + + result = client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42", + ) + + assert_bound_model(result, client) + assert result.storage_box_type.id == 42 + assert result.storage_box_type.name == "bx11" + assert result.location.id == 1 + assert result.location.name == "fsn1" + assert result.system == "FSN1-BX355" + assert result.server == "u1337.your-storagebox.de" + assert result.username == "u12345" + assert result.labels == {"key": "value"} + assert result.protection == {"delete": False} + assert result.snapshot_plan.max_snapshots == 20 + assert result.snapshot_plan.minute == 0 + assert result.snapshot_plan.hour == 7 + assert result.snapshot_plan.day_of_week == 7 + assert result.snapshot_plan.day_of_month is None + assert result.access_settings.reachable_externally is False + assert result.access_settings.samba_enabled is False + assert result.access_settings.ssh_enabled is False + assert result.access_settings.webdav_enabled is False + assert result.access_settings.zfs_enabled is False + assert result.stats.size == 2342236717056 + assert result.stats.size_data == 2102612983808 + assert result.stats.size_snapshots == 239623733248 + assert result.status == "active" + assert result.created == isoparse("2025-01-30T23:55:00Z") + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box1", "page": 1, "per_page": 10}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = client.get_list(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_boxes) == 2 + + result1 = result.storage_boxes[0] + result2 = result.storage_boxes[1] + + assert result1._client is client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is client + assert result2.id == 43 + assert result2.name == "storage-box2" + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = client.get_all(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + 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 == "storage-box1" + + assert result2._client is client + assert result2.id == 43 + assert result2.name == "storage-box2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_boxes": [storage_box1]} + + result = client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes", + params=params, + ) + + assert_bound_model(result, client) + + def test_create( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + action_running1, + ): + request_mock.return_value = { + "storage_box": storage_box1, + "action": action_running1, + } + + result = client.create( + name="storage-box1", + password="secret-password", + location=Location(name="fsn1"), + storage_box_type=StorageBoxType(name="bx11"), + ssh_keys=[], + access_settings=StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes", + json={ + "name": "storage-box1", + "password": "secret-password", + "location": "fsn1", + "storage_box_type": "bx11", + "ssh_keys": [], + "access_settings": { + "reachable_externally": True, + "samba_enabled": False, + "ssh_enabled": True, + }, + "labels": {"key": "value"}, + }, + ) + + assert_bound_model(result.storage_box, client) + + def test_update( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = { + "storage_box": storage_box1, + } + + result = client.update( + StorageBox(id=42), + name="name", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42", + json={ + "name": "name", + "labels": {"key": "value"}, + }, + ) + + assert_bound_model(result, client) + + def test_delete( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + action_running1, + ): + request_mock.return_value = { + "action": action_running1, + } + + result = client.delete(StorageBox(id=42)) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42", + ) + + assert_bound_action(result.action, client._client.actions) + + @pytest.mark.parametrize( + "params", + [ + {"path": "dir1/path"}, + {}, + ], + ) + def test_get_folders( + self, + request_mock: mock.MagicMock, + client: StorageBoxesClient, + params, + ): + request_mock.return_value = { + "folders": ["dir1", "dir2"], + } + + result = client.get_folders(StorageBox(id=42), **params) + + request_mock.assert_called_with( + method="GET", url="/storage_boxes/42/folders", params=params + ) + + assert result.folders == ["dir1", "dir2"] diff --git a/tests/unit/storage_boxes/test_domain.py b/tests/unit/storage_boxes/test_domain.py new file mode 100644 index 00000000..31cc0e35 --- /dev/null +++ b/tests/unit/storage_boxes/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_boxes import StorageBox + + +@pytest.mark.parametrize( + "value", + [ + (StorageBox(id=1),), + ], +) +def test_eq(value): + assert value == value From 347a2edb5d6f09618f8233edc94af24dcc91ff98 Mon Sep 17 00:00:00 2001 From: jo Date: Fri, 15 Aug 2025 10:54:04 +0200 Subject: [PATCH 2/3] use ResourceClientBase --- hcloud/storage_boxes/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 40ac290d..995aeb5a 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import BoundAction -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation, Location from ..storage_box_types import BoundStorageBoxType, StorageBoxType from .domain import ( @@ -63,7 +63,7 @@ class StorageBoxesPageResult(NamedTuple): meta: Meta -class StorageBoxesClient(ClientEntityBase): +class StorageBoxesClient(ResourceClientBase): """ A client for the Storage Boxes API. From 6774fe4a52e0c7b3b8a4c2f4a181cda9a16ffa0e Mon Sep 17 00:00:00 2001 From: jo Date: Fri, 15 Aug 2025 10:59:27 +0200 Subject: [PATCH 3/3] fixes after rebase --- hcloud/storage_boxes/client.py | 24 +++++---- tests/unit/storage_boxes/test_client.py | 70 +++++++++++-------------- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 995aeb5a..f146de9f 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -34,12 +34,12 @@ def __init__( raw = data.get("storage_box_type") if raw is not None: data["storage_box_type"] = BoundStorageBoxType( - client._client.storage_box_types, raw + client._parent.storage_box_types, raw ) raw = data.get("location") if raw is not None: - data["location"] = BoundLocation(client._client.locations, raw) + data["location"] = BoundLocation(client._parent.locations, raw) raw = data.get("snapshot_plan") if raw is not None: @@ -70,7 +70,9 @@ class StorageBoxesClient(ResourceClientBase): See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. """ - _client: Client + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner def get_by_id(self, id: int) -> BoundStorageBox: """ @@ -80,7 +82,7 @@ def get_by_id(self, id: int) -> BoundStorageBox: :param id: ID of the Storage Box. """ - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="GET", url=f"/storage_boxes/{id}", ) @@ -123,7 +125,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_boxes", params=params, @@ -189,7 +191,7 @@ def create( if labels is not None: data["labels"] = labels - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="POST", url="/storage_boxes", json=data, @@ -197,7 +199,7 @@ def create( return CreateStorageBoxResponse( storage_box=BoundStorageBox(self, response["storage_box"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -222,7 +224,7 @@ def update( if labels is not None: data["labels"] = labels - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="PUT", url=f"/storage_boxes/{storage_box.id}", json=data, @@ -241,13 +243,13 @@ def delete( :param storage_box: Storage Box to delete. """ - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="DELETE", url=f"/storage_boxes/{storage_box.id}", ) return DeleteStorageBoxResponse( - action=BoundAction(self._client.actions, response["action"]) + action=BoundAction(self._parent.actions, response["action"]) ) def get_folders( @@ -269,7 +271,7 @@ def get_folders( if path is not None: params["path"] = path - response = self._client._request_hetzner( # pylint: disable=protected-access + response = self._client.request( method="GET", url=f"/storage_boxes/{storage_box.id}/folders", params=params, diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index be24782a..d17367e3 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -21,10 +21,10 @@ def assert_bound_model( o: BoundStorageBox, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, ): assert isinstance(o, BoundStorageBox) - assert o._client is client + assert o._client is resource_client assert o.id == 42 assert o.name == "storage-box1" @@ -38,35 +38,27 @@ def assert_bound_action( assert o.id == 22 -@pytest.fixture(name="request_mock") -def request_mock_fixture(): - return mock.MagicMock() - - -@pytest.fixture(name="client") -def client_fixture(request_mock) -> StorageBoxesClient: - client = Client("") - client._request_hetzner = request_mock - return StorageBoxesClient(client=client) - - class TestClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return StorageBoxesClient(client) + def test_get_by_id( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, ): request_mock.return_value = {"storage_box": storage_box1} - result = client.get_by_id(42) + result = resource_client.get_by_id(42) request_mock.assert_called_with( method="GET", url="/storage_boxes/42", ) - assert_bound_model(result, client) + assert_bound_model(result, resource_client) assert result.storage_box_type.id == 42 assert result.storage_box_type.name == "bx11" assert result.location.id == 1 @@ -102,14 +94,14 @@ def test_get_by_id( def test_get_list( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, storage_box2, params, ): request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} - result = client.get_list(**params) + result = resource_client.get_list(**params) request_mock.assert_called_with( url="/storage_boxes", @@ -123,11 +115,11 @@ def test_get_list( result1 = result.storage_boxes[0] result2 = result.storage_boxes[1] - assert result1._client is client + assert result1._client is resource_client assert result1.id == 42 assert result1.name == "storage-box1" - assert result2._client is client + assert result2._client is resource_client assert result2.id == 43 assert result2.name == "storage-box2" @@ -141,14 +133,14 @@ def test_get_list( def test_get_all( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, storage_box2, params, ): request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} - result = client.get_all(**params) + result = resource_client.get_all(**params) request_mock.assert_called_with( url="/storage_boxes", @@ -161,23 +153,23 @@ def test_get_all( result1 = result[0] result2 = result[1] - assert result1._client is client + assert result1._client is resource_client assert result1.id == 42 assert result1.name == "storage-box1" - assert result2._client is client + assert result2._client is resource_client assert result2.id == 43 assert result2.name == "storage-box2" def test_get_by_name( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, ): request_mock.return_value = {"storage_boxes": [storage_box1]} - result = client.get_by_name("bx11") + result = resource_client.get_by_name("bx11") params = {"name": "bx11"} @@ -187,12 +179,12 @@ def test_get_by_name( params=params, ) - assert_bound_model(result, client) + assert_bound_model(result, resource_client) def test_create( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, action_running1, ): @@ -201,7 +193,7 @@ def test_create( "action": action_running1, } - result = client.create( + result = resource_client.create( name="storage-box1", password="secret-password", location=Location(name="fsn1"), @@ -233,19 +225,19 @@ def test_create( }, ) - assert_bound_model(result.storage_box, client) + assert_bound_model(result.storage_box, resource_client) def test_update( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, storage_box1, ): request_mock.return_value = { "storage_box": storage_box1, } - result = client.update( + result = resource_client.update( StorageBox(id=42), name="name", labels={"key": "value"}, @@ -260,26 +252,26 @@ def test_update( }, ) - assert_bound_model(result, client) + assert_bound_model(result, resource_client) def test_delete( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, action_running1, ): request_mock.return_value = { "action": action_running1, } - result = client.delete(StorageBox(id=42)) + result = resource_client.delete(StorageBox(id=42)) request_mock.assert_called_with( method="DELETE", url="/storage_boxes/42", ) - assert_bound_action(result.action, client._client.actions) + assert_bound_action(result.action, resource_client._parent.actions) @pytest.mark.parametrize( "params", @@ -291,14 +283,14 @@ def test_delete( def test_get_folders( self, request_mock: mock.MagicMock, - client: StorageBoxesClient, + resource_client: StorageBoxesClient, params, ): request_mock.return_value = { "folders": ["dir1", "dir2"], } - result = client.get_folders(StorageBox(id=42), **params) + result = resource_client.get_folders(StorageBox(id=42), **params) request_mock.assert_called_with( method="GET", url="/storage_boxes/42/folders", params=params