From e9f8f74aaa44b781b65fc3c4ec5a352c1f8f8c68 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 7 Aug 2025 15:37:34 +0200 Subject: [PATCH 01/21] feat: add support for Hetzner API endpoint (#525) Adds support for the new Hetzner API endpoint. --- hcloud/_client.py | 12 ++++++++++++ tests/unit/conftest.py | 1 + 2 files changed, 13 insertions(+) diff --git a/hcloud/_client.py b/hcloud/_client.py index 4c499585..066790f0 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -141,11 +141,14 @@ def __init__( poll_interval: int | float | BackoffFunction = 1.0, poll_max_retries: int = 120, timeout: float | tuple[float, float] | None = None, + *, + api_endpoint_hetzner: str = "https://api.hetzner.com/v1", ): """Create a new Client instance :param token: Hetzner Cloud API token :param api_endpoint: Hetzner Cloud API endpoint + :param api_endpoint_hetzner: Hetzner API endpoint. :param application_name: Your application name :param application_version: Your application _version :param poll_interval: @@ -164,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/tests/unit/conftest.py b/tests/unit/conftest.py index 17c37704..7c5b5e51 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -33,6 +33,7 @@ def client(request_mock) -> Client: poll_max_retries=3, ) c._client.request = request_mock + c._client_hetzner.request = request_mock return c From 54fc91e365ddbb649480a649bea7a50a580987c8 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 8 Aug 2025 09:36:56 +0200 Subject: [PATCH 02/21] feat: support Storage Box Types (#527) See https://docs.hetzner.cloud/reference/hetzner#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 | 109 +++++++++++++ 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 ++++++++++++++++++++ tests/unit/storage_box_types/test_domain.py | 15 ++ 9 files changed, 421 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 create mode 100644 tests/unit/storage_box_types/test_domain.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..e0b9ef14 --- /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 066790f0..3764007c 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 from .zones import ZonesClient @@ -273,6 +274,12 @@ def __init__( :type: :class:`ZonesClient ` """ + self.storage_box_types = StorageBoxTypesClient(self) + """StorageBoxTypesClient Instance + + :type: :class:`StorageBoxTypesClient ` + """ + def request( # type: ignore[no-untyped-def] self, method: str, 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..d42d626f --- /dev/null +++ b/hcloud/storage_box_types/client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +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(ResourceClientBase): + """ + A client for the Storage Box Types API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + _base_url = "/storage_box_types" + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + + 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( + method="GET", + url=f"{self._base_url}/{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( + method="GET", + url=f"{self._base_url}", + 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..ee63a0f1 --- /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 import Client +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" + + +class TestClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxTypesClient: + return client.storage_box_types + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_type": storage_box_type1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types/42", + ) + + assert_bound_model(result, resource_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, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_list(**params) + + request_mock.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 resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_all(**params) + + request_mock.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 resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_types": [storage_box_type1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types", + params=params, + ) + + assert_bound_model(result, resource_client) diff --git a/tests/unit/storage_box_types/test_domain.py b/tests/unit/storage_box_types/test_domain.py new file mode 100644 index 00000000..65dbd80f --- /dev/null +++ b/tests/unit/storage_box_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_box_types import StorageBoxType + + +@pytest.mark.parametrize( + "value", + [ + (StorageBoxType(id=1),), + ], +) +def test_eq(value): + assert value == value From 5d39d7dfe052caf41047f4f91cbb1490d399b7ed Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 15 Aug 2025 11:01:49 +0200 Subject: [PATCH 03/21] feat: support Storage Box CRUD (#529) 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 | 282 +++++++++++++++++++++ hcloud/storage_boxes/domain.py | 229 +++++++++++++++++ tests/unit/storage_boxes/__init__.py | 0 tests/unit/storage_boxes/conftest.py | 89 +++++++ tests/unit/storage_boxes/test_client.py | 311 ++++++++++++++++++++++++ tests/unit/storage_boxes/test_domain.py | 15 ++ 9 files changed, 991 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 3764007c..2bf147ec 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 from .zones import ZonesClient @@ -280,6 +281,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..c76f7468 --- /dev/null +++ b/hcloud/storage_boxes/client.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import BoundAction +from ..core import BoundModelBase, Meta, ResourceClientBase +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._parent.storage_box_types, raw + ) + + raw = data.get("location") + if raw is not None: + data["location"] = BoundLocation(client._parent.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(ResourceClientBase): + """ + A client for the Storage Boxes API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + _base_url = "/storage_boxes" + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + + 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( + method="GET", + url=f"{self._base_url}/{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( + method="GET", + url=f"{self._base_url}", + 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.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( + method="POST", + url="/storage_boxes", + json=data, + ) + + return CreateStorageBoxResponse( + storage_box=BoundStorageBox(self, response["storage_box"]), + action=BoundAction(self._parent.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( + method="PUT", + url=f"{self._base_url}/{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( + method="DELETE", + url=f"{self._base_url}/{storage_box.id}", + ) + + return DeleteStorageBoxResponse( + action=BoundAction(self._parent.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( + method="GET", + url=f"{self._base_url}/{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..6b3a67fb --- /dev/null +++ b/tests/unit/storage_boxes/conftest.py @@ -0,0 +1,89 @@ +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}, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py new file mode 100644 index 00000000..77ff925f --- /dev/null +++ b/tests/unit/storage_boxes/test_client.py @@ -0,0 +1,311 @@ +# 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.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 + +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_model( + o: BoundStorageBox, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBox) + assert o._client is resource_client + assert o.id == 42 + assert o.name == "storage-box1" + + +class TestBoundStorageBox(BoundModelTestCase): + methods = [] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, resource_client: StorageBoxesClient, storage_box1 + ) -> BoundStorageBox: + return BoundStorageBox(resource_client, data=storage_box1) + + def test_init(self, bound_model, resource_client): + o = bound_model + + assert_bound_model(o, resource_client) + # TODO: test all properties + + +class TestStorageBoxClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_box": storage_box1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42", + ) + + 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 + 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, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_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 resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is resource_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, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_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 resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + 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, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_boxes": [storage_box1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes", + params=params, + ) + + assert_bound_model(result, resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + action1_running, + ): + request_mock.return_value = { + "storage_box": storage_box1, + "action": action1_running, + } + + result = resource_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, resource_client) + + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = { + "storage_box": storage_box1, + } + + result = resource_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, resource_client) + + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete(StorageBox(id=42)) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {"path": "dir1/path"}, + {}, + ], + ) + def test_get_folders( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + params, + ): + request_mock.return_value = { + "folders": ["dir1", "dir2"], + } + + result = resource_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 f94a98fa24e8a0ba9309cdcf2fa5e9639a9229b4 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 10 Nov 2025 18:11:29 +0100 Subject: [PATCH 04/21] feat: support Storage Box Actions (#541) - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan - https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan --- hcloud/storage_boxes/client.py | 282 +++++++++++++++++++++++- hcloud/storage_boxes/domain.py | 49 +++- tests/unit/actions/test_client.py | 2 + tests/unit/storage_boxes/test_client.py | 168 +++++++++++++- 4 files changed, 491 insertions(+), 10 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index c76f7468..498963ff 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import BoundAction +from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation, Location from ..storage_box_types import BoundStorageBoxType, StorageBoxType @@ -12,6 +12,7 @@ StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, + StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxStats, ) @@ -55,6 +56,52 @@ def __init__( super().__init__(client, data, complete) + def get_actions_list( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for the Storage Box for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + # TODO: implement bound methods @@ -72,9 +119,16 @@ class StorageBoxesClient(ResourceClientBase): _base_url = "/storage_boxes" + actions: ResourceActionsClient + """Storage Boxes scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + def __init__(self, client: Client): super().__init__(client) self._client = client._client_hetzner + self.actions = ResourceActionsClient(self, self._base_url) def get_by_id(self, id: int) -> BoundStorageBox: """ @@ -241,7 +295,7 @@ def delete( """ Deletes a Storage Box. - See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-storage-box + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box :param storage_box: Storage Box to delete. """ @@ -264,7 +318,7 @@ def get_folders( Files are not part of the response. - See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-content-of-storage-box + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box :param storage_box: Storage Box to list the folders from. :param path: Relative path to list the folders from. @@ -280,3 +334,225 @@ def get_folders( ) return StorageBoxFoldersResponse(folders=response["folders"]) + + def get_actions_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for a Storage Box for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"/storage_boxes/{storage_box.id}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + def get_actions( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + storage_box, + status=status, + sort=sort, + ) + + def change_protection( + self, + storage_box: StorageBox | BoundStorageBox, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param storage_box: Storage Box to update. + :param delete: Prevents the Storage Box from being deleted. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_type( + self, + storage_box: StorageBox | BoundStorageBox, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box: Storage Box to update. + :param storage_box_type: Storage Box Type to change to. + """ + data: dict[str, Any] = { + "storage_box_type": storage_box_type.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_type", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_password( + self, + storage_box: StorageBox | BoundStorageBox, + *, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param storage_box: Storage Box to update. + :param password: New password. + """ + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/reset_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_access_settings( + self, + storage_box: StorageBox | BoundStorageBox, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param storage_box: Storage Box to update. + :param access_settings: New access settings for the Storage Box. + """ + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def rollback_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot: StorageBoxSnapshot, # TODO: Add BoundStorageBoxSnapshot + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param storage_box: Storage Box to update. + :param snapshot: Snapshot to rollback to. + """ + data: dict[str, Any] = { + "snapshot": snapshot.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/rollback_snapshot", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def disable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + ) -> BoundAction: + """ + Disable the snapshot plan a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + + :param storage_box: Storage Box to update. + """ + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/disable_snapshot_plan", + ) + return BoundAction(self._parent.actions, response["action"]) + + def enable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param storage_box: Storage Box to update. + :param snapshot_plan: Snapshot Plan to enable. + """ + data: dict[str, Any] = snapshot_plan.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/enable_snapshot_plan", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index d324b6f4..6f01f406 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -157,8 +157,8 @@ class StorageBoxSnapshotPlan(BaseDomain): __api_properties__ = ( "max_snapshots", - "minute", "hour", + "minute", "day_of_week", "day_of_month", ) @@ -166,18 +166,32 @@ class StorageBoxSnapshotPlan(BaseDomain): def __init__( self, - max_snapshots: int | None = None, - minute: int | None = None, - hour: int | None = None, + max_snapshots: int, + hour: int, + minute: int, day_of_week: int | None = None, day_of_month: int | None = None, ): self.max_snapshots = max_snapshots - self.minute = minute self.hour = hour + self.minute = minute self.day_of_week = day_of_week self.day_of_month = day_of_month + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "max_snapshots": self.max_snapshots, + "hour": self.hour, + "minute": self.minute, + "day_of_week": self.day_of_week, # API default is null + "day_of_month": self.day_of_month, # API default is null + } + + return payload + class CreateStorageBoxResponse(BaseDomain): """ @@ -227,3 +241,28 @@ def __init__( folders: list[str], ): self.folders = folders + + +# Snapshots +############################################################################### + + +class StorageBoxSnapshot(BaseDomain, DomainIdentityMixin): + """ + Storage Box Snapshot Domain. + """ + + # TODO: full domain + __api_properties__ = ( + "id", + "name", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + ): + self.id = id + self.name = name diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 6c09f7dd..0b708cfb 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -22,6 +22,7 @@ from hcloud.networks import BoundNetwork, NetworksClient from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient from hcloud.servers import BoundServer, ServersClient +from hcloud.storage_boxes import BoundStorageBox, StorageBoxesClient from hcloud.volumes import BoundVolume, VolumesClient from hcloud.zones import BoundZone, ZonesClient @@ -38,6 +39,7 @@ "servers": (ServersClient, BoundServer), "volumes": (VolumesClient, BoundVolume), "zones": (ZonesClient, BoundZone), + "storage_boxes": (StorageBoxesClient, BoundStorageBox), } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 77ff925f..b1eab37c 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -14,8 +14,9 @@ BoundStorageBox, StorageBox, StorageBoxesClient, + StorageBoxSnapshotPlan, ) -from hcloud.storage_boxes.domain import StorageBoxAccessSettings +from hcloud.storage_boxes.domain import StorageBoxAccessSettings, StorageBoxSnapshot from ..conftest import BoundModelTestCase, assert_bound_action1 @@ -39,7 +40,9 @@ def resource_client(self, client: Client) -> StorageBoxesClient: @pytest.fixture() def bound_model( - self, resource_client: StorageBoxesClient, storage_box1 + self, + resource_client: StorageBoxesClient, + storage_box1, ) -> BoundStorageBox: return BoundStorageBox(resource_client, data=storage_box1) @@ -309,3 +312,164 @@ def test_get_folders( ) assert result.folders == ["dir1", "dir2"] + + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(StorageBox(id=42), delete=True) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_change_type( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + StorageBox(id=42), + StorageBoxType(name="bx21"), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_type", + json={"storage_box_type": "bx21"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_password( + StorageBox(id=42), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/reset_password", + json={"password": "password"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_access_settings( + StorageBox(id=42), + StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + webdav_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "webdav_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_rollback_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.rollback_snapshot( + StorageBox(id=42), + StorageBoxSnapshot(id=32), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/rollback_snapshot", + json={"snapshot": 32}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_disable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.disable_snapshot_plan( + StorageBox(id=42), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/disable_snapshot_plan", + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_enable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.enable_snapshot_plan( + StorageBox(id=42), + StorageBoxSnapshotPlan( + max_snapshots=10, + hour=3, + minute=30, + day_of_week=None, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/enable_snapshot_plan", + json={ + "max_snapshots": 10, + "hour": 3, + "minute": 30, + "day_of_week": None, + "day_of_month": None, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) From 2b2c9fea8a8c147c6e7b6d65562c78ef9812ba73 Mon Sep 17 00:00:00 2001 From: jo Date: Wed, 12 Nov 2025 18:11:13 +0100 Subject: [PATCH 05/21] refactor: fixes after cherry-pick --- hcloud/storage_box_types/client.py | 2 +- hcloud/storage_boxes/client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index d42d626f..c37023c2 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -55,7 +55,7 @@ def get_by_name(self, name: str) -> BoundStorageBoxType | None: :param name: Name of the Storage Box Type. """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_list( self, diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 498963ff..f9390ac9 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -152,7 +152,7 @@ def get_by_name(self, name: str) -> BoundStorageBox | None: :param name: Name of the Storage Box. """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_list( self, From 13f92ba89ca4772995ea021dd533f27424fa7816 Mon Sep 17 00:00:00 2001 From: jo Date: Wed, 12 Nov 2025 18:12:57 +0100 Subject: [PATCH 06/21] style: sort __all__ --- hcloud/storage_box_types/__init__.py | 2 +- hcloud/storage_boxes/__init__.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hcloud/storage_box_types/__init__.py b/hcloud/storage_box_types/__init__.py index 4e6818c4..28d832b0 100644 --- a/hcloud/storage_box_types/__init__.py +++ b/hcloud/storage_box_types/__init__.py @@ -9,7 +9,7 @@ __all__ = [ "BoundStorageBoxType", + "StorageBoxType", "StorageBoxTypesClient", "StorageBoxTypesPageResult", - "StorageBoxType", ] diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py index 3c241c55..5f654466 100644 --- a/hcloud/storage_boxes/__init__.py +++ b/hcloud/storage_boxes/__init__.py @@ -17,13 +17,13 @@ __all__ = [ "BoundStorageBox", + "CreateStorageBoxResponse", + "DeleteStorageBoxResponse", + "StorageBox", + "StorageBoxAccessSettings", "StorageBoxesClient", "StorageBoxesPageResult", - "StorageBox", + "StorageBoxFoldersResponse", "StorageBoxSnapshotPlan", "StorageBoxStats", - "StorageBoxAccessSettings", - "CreateStorageBoxResponse", - "DeleteStorageBoxResponse", - "StorageBoxFoldersResponse", ] From 0bd1ab785f8a69c12af92706ac35adc5e408e969 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 13 Nov 2025 14:56:13 +0100 Subject: [PATCH 07/21] feat: support Storage Box Snapshot CRUD (#586) - https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots - https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot - https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot - https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot - https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot --- hcloud/storage_boxes/__init__.py | 6 + hcloud/storage_boxes/client.py | 229 +++++++++++++++++ hcloud/storage_boxes/domain.py | 76 +++++- tests/unit/storage_boxes/conftest.py | 42 +++ tests/unit/storage_boxes/test_client.py | 323 +++++++++++++++++++++--- 5 files changed, 641 insertions(+), 35 deletions(-) diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py index 5f654466..50276417 100644 --- a/hcloud/storage_boxes/__init__.py +++ b/hcloud/storage_boxes/__init__.py @@ -2,8 +2,10 @@ from .client import ( BoundStorageBox, + BoundStorageBoxSnapshot, StorageBoxesClient, StorageBoxesPageResult, + StorageBoxSnapshotsPageResult, ) from .domain import ( CreateStorageBoxResponse, @@ -11,12 +13,14 @@ StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, + StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxStats, ) __all__ = [ "BoundStorageBox", + "BoundStorageBoxSnapshot", "CreateStorageBoxResponse", "DeleteStorageBoxResponse", "StorageBox", @@ -24,6 +28,8 @@ "StorageBoxesClient", "StorageBoxesPageResult", "StorageBoxFoldersResponse", + "StorageBoxSnapshot", "StorageBoxSnapshotPlan", + "StorageBoxSnapshotsPageResult", "StorageBoxStats", ] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index f9390ac9..c1ec9c26 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -8,12 +8,15 @@ from ..storage_box_types import BoundStorageBoxType, StorageBoxType from .domain import ( CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, StorageBoxSnapshot, StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, StorageBoxStats, ) @@ -105,11 +108,42 @@ def get_actions( # TODO: implement bound methods +class BoundStorageBoxSnapshot(BoundModelBase, StorageBoxSnapshot): + _client: StorageBoxesClient + + model = StorageBoxSnapshot + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxSnapshotStats.from_dict(raw) + + super().__init__(client, data, complete) + + # TODO: implement bound methods + + class StorageBoxesPageResult(NamedTuple): storage_boxes: list[BoundStorageBox] meta: Meta +class StorageBoxSnapshotsPageResult(NamedTuple): + snapshots: list[BoundStorageBoxSnapshot] + meta: Meta + + class StorageBoxesClient(ResourceClientBase): """ A client for the Storage Boxes API. @@ -556,3 +590,198 @@ def enable_snapshot_plan( json=data, ) return BoundAction(self._parent.actions, response["action"]) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param storage_box: Storage Box to get the Snapshot from. + :param id: ID of the Snapshot. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots/{id}", + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def get_snapshot_by_name( + self, + storage_box: StorageBox | BoundStorageBox, + name: str, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshot from. + :param name: Name of the Snapshot. + """ + return self._get_first_by(self.get_snapshot_list, storage_box, name=name) + + def get_snapshot_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if is_automatic is not None: + params["is_automatic"] = is_automatic + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots", + params=params, + ) + return StorageBoxSnapshotsPageResult( + snapshots=[ + BoundStorageBoxSnapshot(self, item) for item in response["snapshots"] + ], + meta=Meta.parse_meta(response), + ) + + def get_snapshot_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_snapshot_list( + storage_box, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param storage_box: Storage Box to create a Snapshot from. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/snapshots", + json=data, + ) + return CreateStorageBoxSnapshotResponse( + snapshot=BoundStorageBoxSnapshot(self, response["snapshot"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param snapshot: Storage Box Snapshot to update. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + json=data, + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def delete_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + + :param snapshot: Storage Box Snapshot to delete. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + ) + return DeleteStorageBoxSnapshotResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 6f01f406..5414cbf1 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -10,7 +10,7 @@ from ..storage_box_types import BoundStorageBoxType, StorageBoxType if TYPE_CHECKING: - from .client import BoundStorageBox + from .client import BoundStorageBox, BoundStorageBoxSnapshot StorageBoxStatus = Literal[ "active", @@ -252,10 +252,15 @@ class StorageBoxSnapshot(BaseDomain, DomainIdentityMixin): Storage Box Snapshot Domain. """ - # TODO: full domain __api_properties__ = ( "id", "name", + "description", + "is_automatic", + "labels", + "storage_box", + "created", + "stats", ) __slots__ = __api_properties__ @@ -263,6 +268,73 @@ def __init__( self, id: int | None = None, name: str | None = None, + description: str | None = None, + is_automatic: bool | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + stats: StorageBoxSnapshotStats | None = None, ): self.id = id self.name = name + self.description = description + self.is_automatic = is_automatic + self.labels = labels + self.storage_box = storage_box + self.created = isoparse(created) if created else None + self.stats = stats + + +class StorageBoxSnapshotStats(BaseDomain): + """ + Storage Box Snapshot Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_filesystem", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int, + size_filesystem: int, + ): + self.size = size + self.size_filesystem = size_filesystem + + +class CreateStorageBoxSnapshotResponse(BaseDomain): + """ + Create Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ( + "snapshot", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + snapshot: BoundStorageBoxSnapshot, + action: BoundAction, + ): + self.snapshot = snapshot + self.action = action + + +class DeleteStorageBoxSnapshotResponse(BaseDomain): + """ + Delete Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py index 6b3a67fb..561123dc 100644 --- a/tests/unit/storage_boxes/conftest.py +++ b/tests/unit/storage_boxes/conftest.py @@ -87,3 +87,45 @@ def storage_box2(): "labels": {}, "protection": {"delete": False}, } + + +@pytest.fixture() +def storage_box_snapshot1(): + return { + "id": 34, + "name": "storage-box-snapshot1", + "description": "", + "is_automatic": False, + "stats": { + "size": 394957594, + "size_filesystem": 3949572745, + }, + "labels": { + "key": "value", + }, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:16:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_snapshot2(): + return { + "id": 35, + "name": "storage-box-snapshot2", + "description": "", + "is_automatic": True, + "stats": { + "size": 0, + "size_filesystem": 0, + }, + "labels": {}, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index b1eab37c..9a477258 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -12,16 +12,18 @@ from hcloud.storage_box_types import StorageBoxType from hcloud.storage_boxes import ( BoundStorageBox, + BoundStorageBoxSnapshot, StorageBox, + StorageBoxAccessSettings, StorageBoxesClient, + StorageBoxSnapshot, StorageBoxSnapshotPlan, ) -from hcloud.storage_boxes.domain import StorageBoxAccessSettings, StorageBoxSnapshot from ..conftest import BoundModelTestCase, assert_bound_action1 -def assert_bound_model( +def assert_bound_storage_box( o: BoundStorageBox, resource_client: StorageBoxesClient, ): @@ -31,6 +33,16 @@ def assert_bound_model( assert o.name == "storage-box1" +def assert_bound_storage_box_snapshot( + o: BoundStorageBox, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSnapshot) + assert o._client is resource_client + assert o.id == 34 + assert o.name == "storage-box-snapshot1" + + class TestBoundStorageBox(BoundModelTestCase): methods = [] @@ -46,11 +58,66 @@ def bound_model( ) -> BoundStorageBox: return BoundStorageBox(resource_client, data=storage_box1) - def test_init(self, bound_model, resource_client): + def test_init(self, bound_model: BoundStorageBox, resource_client): + o = bound_model + + assert_bound_storage_box(o, resource_client) + + assert o.storage_box_type.id == 42 + assert o.storage_box_type.name == "bx11" + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.system == "FSN1-BX355" + assert o.server == "u1337.your-storagebox.de" + assert o.username == "u12345" + assert o.labels == {"key": "value"} + assert o.protection == {"delete": False} + assert o.snapshot_plan.max_snapshots == 20 + assert o.snapshot_plan.minute == 0 + assert o.snapshot_plan.hour == 7 + assert o.snapshot_plan.day_of_week == 7 + assert o.snapshot_plan.day_of_month is None + assert o.access_settings.reachable_externally is False + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is False + assert o.access_settings.webdav_enabled is False + assert o.access_settings.zfs_enabled is False + assert o.stats.size == 2342236717056 + assert o.stats.size_data == 2102612983808 + assert o.stats.size_snapshots == 239623733248 + assert o.status == "active" + assert o.created == isoparse("2025-01-30T23:55:00Z") + + +class TestBoundStorageBoxSnapshot(BoundModelTestCase): + methods = [] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ) -> BoundStorageBoxSnapshot: + return BoundStorageBoxSnapshot(resource_client, data=storage_box_snapshot1) + + def test_init(self, bound_model: BoundStorageBoxSnapshot, resource_client): o = bound_model - assert_bound_model(o, resource_client) - # TODO: test all properties + assert_bound_storage_box_snapshot(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.description == "" + assert o.is_automatic is False + assert o.labels == {"key": "value"} + assert o.stats.size == 394957594 + assert o.stats.size_filesystem == 3949572745 + assert o.created == isoparse("2025-11-10T19:16:57Z") class TestStorageBoxClient: @@ -73,31 +140,7 @@ def test_get_by_id( url="/storage_boxes/42", ) - 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 - 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") + assert_bound_storage_box(result, resource_client) @pytest.mark.parametrize( "params", @@ -194,7 +237,7 @@ def test_get_by_name( params=params, ) - assert_bound_model(result, resource_client) + assert_bound_storage_box(result, resource_client) def test_create( self, @@ -240,7 +283,7 @@ def test_create( }, ) - assert_bound_model(result.storage_box, resource_client) + assert_bound_storage_box(result.storage_box, resource_client) def test_update( self, @@ -267,7 +310,7 @@ def test_update( }, ) - assert_bound_model(result, resource_client) + assert_bound_storage_box(result, resource_client) def test_delete( self, @@ -473,3 +516,217 @@ def test_enable_snapshot_plan( ) assert_bound_action1(action, resource_client._parent.actions) + + # Snapshots + ########################################################################### + + def test_get_snapshot_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + result = resource_client.get_snapshot_by_id(StorageBox(42), 34) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {}, + ], + ) + def test_get_snapshot_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.snapshots) == 2 + + result1 = result.snapshots[0] + result2 = result.snapshots[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {}, + ], + ) + def test_get_snapshot_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_snapshot_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshots": [storage_box_snapshot1]} + + result = resource_client.get_snapshot_by_name( + StorageBox(42), "storage-box-snapshot1" + ) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots", + params={"name": "storage-box-snapshot1"}, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_create_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1: dict, + action1_running, + ): + request_mock.return_value = { + "snapshot": { + # Only a partial snapshot is returned + key: storage_box_snapshot1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_snapshot( + StorageBox(42), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/snapshots", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.snapshot, BoundStorageBoxSnapshot) + assert result.snapshot._client is resource_client + assert result.snapshot.id == 34 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = { + "snapshot": storage_box_snapshot1, + } + + result = resource_client.update_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/snapshots/34", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_delete_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)) + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) From af4fa0d121df6d170c3266302bf6b175c66812b0 Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 13 Nov 2025 19:52:35 +0100 Subject: [PATCH 08/21] fix: support snapshot partial objects --- hcloud/storage_boxes/client.py | 13 ++++++++++++- tests/unit/storage_boxes/test_client.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index c1ec9c26..08e04c0f 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -131,6 +131,12 @@ def __init__( super().__init__(client, data, complete) + def _get_self(self) -> BoundStorageBoxSnapshot: + return self._client.get_snapshot_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + # TODO: implement bound methods @@ -728,7 +734,12 @@ def create_snapshot( json=data, ) return CreateStorageBoxSnapshotResponse( - snapshot=BoundStorageBoxSnapshot(self, response["snapshot"]), + snapshot=BoundStorageBoxSnapshot( + self, + response["snapshot"], + # API only returns a partial object. + complete=False, + ), action=BoundAction(self._parent.actions, response["action"]), ) diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 9a477258..c895ae38 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -119,6 +119,25 @@ def test_init(self, bound_model: BoundStorageBoxSnapshot, resource_client): assert o.stats.size_filesystem == 3949572745 assert o.created == isoparse("2025-11-10T19:16:57Z") + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + o = BoundStorageBoxSnapshot(resource_client, data={"id": 34, "storage_box": 42}) + + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert o.labels is not None + class TestStorageBoxClient: @pytest.fixture() From ae2377240758efb0eabcbe674c6bbbc06cb78192 Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 13 Nov 2025 20:44:19 +0100 Subject: [PATCH 09/21] docs: fix --- hcloud/storage_boxes/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 08e04c0f..19da0d5f 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -518,7 +518,7 @@ def update_access_settings( access_settings: StorageBoxAccessSettings, ) -> BoundAction: """ - Reset the password of a Storage Box. + Update the access settings of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings From a8a16cd2b857cfc501cc7d3136ad6bd65c871eda Mon Sep 17 00:00:00 2001 From: jo Date: Fri, 14 Nov 2025 12:23:03 +0100 Subject: [PATCH 10/21] test: fix linting error --- tests/unit/storage_box_types/test_domain.py | 2 +- tests/unit/storage_boxes/test_domain.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/storage_box_types/test_domain.py b/tests/unit/storage_box_types/test_domain.py index 65dbd80f..829c906a 100644 --- a/tests/unit/storage_box_types/test_domain.py +++ b/tests/unit/storage_box_types/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/storage_boxes/test_domain.py b/tests/unit/storage_boxes/test_domain.py index 31cc0e35..eef73e06 100644 --- a/tests/unit/storage_boxes/test_domain.py +++ b/tests/unit/storage_boxes/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) From eab7e6f47d90e5f2b240a957ca90e2ac90e55e24 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 17 Nov 2025 10:44:09 +0100 Subject: [PATCH 11/21] feat: support Storage Box Subaccount CRUD (#589) - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount --- hcloud/storage_boxes/__init__.py | 16 ++ hcloud/storage_boxes/client.py | 250 ++++++++++++++++++++ hcloud/storage_boxes/domain.py | 132 ++++++++++- tests/unit/storage_boxes/conftest.py | 50 ++++ tests/unit/storage_boxes/test_client.py | 300 +++++++++++++++++++++++- 5 files changed, 745 insertions(+), 3 deletions(-) diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py index 50276417..33dbe459 100644 --- a/hcloud/storage_boxes/__init__.py +++ b/hcloud/storage_boxes/__init__.py @@ -3,26 +3,39 @@ from .client import ( BoundStorageBox, BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, StorageBoxesClient, StorageBoxesPageResult, StorageBoxSnapshotsPageResult, + StorageBoxSubaccountsPageResult, ) from .domain import ( CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, StorageBoxSnapshot, StorageBoxSnapshotPlan, StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) __all__ = [ "BoundStorageBox", "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", "CreateStorageBoxResponse", + "CreateStorageBoxSnapshotResponse", + "CreateStorageBoxSubaccountResponse", "DeleteStorageBoxResponse", + "DeleteStorageBoxSnapshotResponse", + "DeleteStorageBoxSubaccountResponse", "StorageBox", "StorageBoxAccessSettings", "StorageBoxesClient", @@ -32,4 +45,7 @@ "StorageBoxSnapshotPlan", "StorageBoxSnapshotsPageResult", "StorageBoxStats", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "StorageBoxSubaccountsPageResult", ] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 19da0d5f..267b2c0f 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -9,8 +9,10 @@ from .domain import ( CreateStorageBoxResponse, CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, DeleteStorageBoxResponse, DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, StorageBox, StorageBoxAccessSettings, StorageBoxFoldersResponse, @@ -18,6 +20,8 @@ StorageBoxSnapshotPlan, StorageBoxSnapshotStats, StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) if TYPE_CHECKING: @@ -140,6 +144,38 @@ def _get_self(self) -> BoundStorageBoxSnapshot: # TODO: implement bound methods +class BoundStorageBoxSubaccount(BoundModelBase, StorageBoxSubaccount): + _client: StorageBoxesClient + + model = StorageBoxSubaccount + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxSubaccountAccessSettings.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSubaccount: + return self._client.get_subaccount_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + # TODO: implement bound methods + + class StorageBoxesPageResult(NamedTuple): storage_boxes: list[BoundStorageBox] meta: Meta @@ -150,6 +186,11 @@ class StorageBoxSnapshotsPageResult(NamedTuple): meta: Meta +class StorageBoxSubaccountsPageResult(NamedTuple): + subaccounts: list[BoundStorageBoxSubaccount] + meta: Meta + + class StorageBoxesClient(ResourceClientBase): """ A client for the Storage Boxes API. @@ -796,3 +837,212 @@ def delete_snapshot( return DeleteStorageBoxSnapshotResponse( action=BoundAction(self._parent.actions, response["action"]), ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param storage_box: Storage Box to get the Subaccount from. + :param id: ID of the Subaccount. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts/{id}", + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def get_subaccount_by_username( + self, + storage_box: StorageBox | BoundStorageBox, + username: str, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: User name of the Subaccount. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + username=username, + ) + + def get_subaccount_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + params: dict[str, Any] = {} + if username is not None: + params["username"] = username + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + params=params, + ) + return StorageBoxSubaccountsPageResult( + subaccounts=[ + BoundStorageBoxSubaccount(self, item) + for item in response["subaccounts"] + ], + meta=Meta.parse_meta(response), + ) + + def get_subaccount_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_subaccount_list( + storage_box, + username=username, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_subaccount( + self, + storage_box: StorageBox | BoundStorageBox, + *, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access Settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + data: dict[str, Any] = { + "home_directory": home_directory, + "password": password, + } + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + json=data, + ) + return CreateStorageBoxSubaccountResponse( + subaccount=BoundStorageBoxSubaccount( + self, + response["subaccount"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param subaccount: Storage Box Subaccount to update. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + json=data, + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def delete_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + :param subaccount: Storage Box Subaccount to delete. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + ) + return DeleteStorageBoxSubaccountResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 5414cbf1..7d94fcb2 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -10,7 +10,11 @@ from ..storage_box_types import BoundStorageBoxType, StorageBoxType if TYPE_CHECKING: - from .client import BoundStorageBox, BoundStorageBoxSnapshot + from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + ) StorageBoxStatus = Literal[ "active", @@ -338,3 +342,129 @@ def __init__( action: BoundAction, ): self.action = action + + +# Subaccounts +############################################################################### + + +class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): + """ + Storage Box Subaccount Domain. + """ + + __api_properties__ = ( + "id", + "username", + "description", + "server", + "home_directory", + "access_settings", + "labels", + "storage_box", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + username: str | None = None, + description: str | None = None, + server: str | None = None, + home_directory: str | None = None, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + ): + self.id = id + self.username = username + self.description = description + self.server = server + self.home_directory = home_directory + self.access_settings = access_settings + self.labels = labels + self.storage_box = storage_box + self.created = isoparse(created) if created else None + + +class StorageBoxSubaccountAccessSettings(BaseDomain): + """ + Storage Box Subaccount Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "readonly", + ) + __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, + readonly: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.readonly = readonly + + 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.readonly is not None: + payload["readonly"] = self.readonly + return payload + + +class CreateStorageBoxSubaccountResponse(BaseDomain): + """ + Create Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ( + "subaccount", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + subaccount: BoundStorageBoxSubaccount, + action: BoundAction, + ): + self.subaccount = subaccount + self.action = action + + +class DeleteStorageBoxSubaccountResponse(BaseDomain): + """ + Delete Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py index 561123dc..48f1696d 100644 --- a/tests/unit/storage_boxes/conftest.py +++ b/tests/unit/storage_boxes/conftest.py @@ -129,3 +129,53 @@ def storage_box_snapshot2(): "created": "2025-11-10T19:18:57Z", "storage_box": 42, } + + +@pytest.fixture() +def storage_box_subaccount1(): + return { + "id": 45, + "username": "u42-sub1", + "server": "u42-sub1.your-storagebox.de", + "home_directory": "tmp/", + "description": "Required by foo", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": { + "key": "value", + }, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount2(): + return { + "id": 46, + "username": "u42-sub2", + "server": "u42-sub2.your-storagebox.de", + "home_directory": "backup/", + "description": "", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": {}, + "protection": { + "delete": False, + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index c895ae38..1503120b 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -13,11 +13,14 @@ from hcloud.storage_boxes import ( BoundStorageBox, BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, StorageBox, StorageBoxAccessSettings, StorageBoxesClient, StorageBoxSnapshot, StorageBoxSnapshotPlan, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, ) from ..conftest import BoundModelTestCase, assert_bound_action1 @@ -34,7 +37,7 @@ def assert_bound_storage_box( def assert_bound_storage_box_snapshot( - o: BoundStorageBox, + o: BoundStorageBoxSnapshot, resource_client: StorageBoxesClient, ): assert isinstance(o, BoundStorageBoxSnapshot) @@ -43,6 +46,16 @@ def assert_bound_storage_box_snapshot( assert o.name == "storage-box-snapshot1" +def assert_bound_storage_box_subaccount( + o: BoundStorageBoxSubaccount, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSubaccount) + assert o._client is resource_client + assert o.id == 45 + assert o.username == "u42-sub1" + + class TestBoundStorageBox(BoundModelTestCase): methods = [] @@ -139,6 +152,63 @@ def test_reload( assert o.labels is not None +class TestBoundStorageBoxSubaccount(BoundModelTestCase): + methods = [] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ) -> BoundStorageBoxSubaccount: + return BoundStorageBoxSubaccount(resource_client, data=storage_box_subaccount1) + + def test_init(self, bound_model: BoundStorageBoxSubaccount, resource_client): + o = bound_model + + assert_bound_storage_box_subaccount(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.username == "u42-sub1" + assert o.description == "Required by foo" + assert o.server == "u42-sub1.your-storagebox.de" + assert o.home_directory == "tmp/" + assert o.access_settings.reachable_externally is True + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is True + assert o.access_settings.webdav_enabled is False + assert o.access_settings.readonly is False + assert o.labels == {"key": "value"} + assert o.created == isoparse("2025-11-10T19:18:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + o = BoundStorageBoxSubaccount( + resource_client, data={"id": 45, "storage_box": 42} + ) + + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert o.labels is not None + + class TestStorageBoxClient: @pytest.fixture() def resource_client(self, client: Client) -> StorageBoxesClient: @@ -674,7 +744,7 @@ def test_create_snapshot( ): request_mock.return_value = { "snapshot": { - # Only a partial snapshot is returned + # Only a partial object is returned key: storage_box_snapshot1[key] for key in ["id", "storage_box"] }, @@ -749,3 +819,229 @@ def test_delete_snapshot( ) assert_bound_action1(result.action, resource_client._parent.actions) + + # Subaccounts + ########################################################################### + + def test_get_subaccount_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + result = resource_client.get_subaccount_by_id(StorageBox(42), 45) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {}, + ], + ) + def test_get_subaccount_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.subaccounts) == 2 + + result1 = result.subaccounts[0] + result2 = result.subaccounts[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {}, + ], + ) + def test_get_subaccount_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_subaccount_by_username( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_username(StorageBox(42), "u42-sub1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"username": "u42-sub1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_create_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1: dict, + action1_running, + ): + request_mock.return_value = { + "subaccount": { + # Only a partial object is returned + key: storage_box_subaccount1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_subaccount( + StorageBox(42), + home_directory="tmp", + password="secret", + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + readonly=False, + ), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts", + json={ + "home_directory": "tmp", + "password": "secret", + "access_settings": { + "reachable_externally": True, + "ssh_enabled": True, + "readonly": False, + }, + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.subaccount, BoundStorageBoxSubaccount) + assert result.subaccount._client is resource_client + assert result.subaccount.id == 45 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = { + "subaccount": storage_box_subaccount1, + } + + result = resource_client.update_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/subaccounts/45", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_delete_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) From 20671bf7f074a2682f26e0e05929f7c5bcbb56ba Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 17 Nov 2025 11:15:34 +0100 Subject: [PATCH 12/21] feat: support Storage Box Subaccount Actions (#591) - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password - https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings --- hcloud/storage_boxes/client.py | 79 +++++++++++++++++++++++++ tests/unit/storage_boxes/test_client.py | 75 +++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 267b2c0f..7d3e9755 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -1046,3 +1046,82 @@ def delete_subaccount( return DeleteStorageBoxSubaccountResponse( action=BoundAction(self._parent.actions, response["action"]), ) + + def change_subaccount_home_directory( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param subaccount: Storage Box Subaccount to update. + :param home_directory: Home directory for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "home_directory": home_directory, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/change_home_directory", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_subaccount_password( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param subaccount: Storage Box Subaccount to update. + :param password: Password for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/reset_subaccount_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_subaccount_access_settings( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param subaccount: Storage Box Subaccount to update. + :param access_settings: Access settings for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 1503120b..4538db3c 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -1045,3 +1045,78 @@ def test_delete_subaccount( ) assert_bound_action1(result.action, resource_client._parent.actions) + + def test_change_subaccount_home_directory( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_subaccount_home_directory( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + home_directory="path", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/change_home_directory", + json={ + "home_directory": "path", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_subaccount_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_subaccount_password( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/reset_subaccount_password", + json={ + "password": "password", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_subaccount_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_subaccount_access_settings( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "samba_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) From 92ecbedc12d108869808813c5f6c675d231a4af1 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 17 Nov 2025 11:17:10 +0100 Subject: [PATCH 13/21] feat: implement storage boxes bound methods and various fixes (#593) --- hcloud/storage_boxes/__init__.py | 4 + hcloud/storage_boxes/client.py | 499 +++++++++++++++++++++++- tests/unit/storage_boxes/test_client.py | 38 +- 3 files changed, 519 insertions(+), 22 deletions(-) diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py index 33dbe459..4eb22722 100644 --- a/hcloud/storage_boxes/__init__.py +++ b/hcloud/storage_boxes/__init__.py @@ -21,7 +21,9 @@ StorageBoxFoldersResponse, StorageBoxSnapshot, StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, StorageBoxStats, + StorageBoxStatus, StorageBoxSubaccount, StorageBoxSubaccountAccessSettings, ) @@ -44,7 +46,9 @@ "StorageBoxSnapshot", "StorageBoxSnapshotPlan", "StorageBoxSnapshotsPageResult", + "StorageBoxSnapshotStats", "StorageBoxStats", + "StorageBoxStatus", "StorageBoxSubaccount", "StorageBoxSubaccountAccessSettings", "StorageBoxSubaccountsPageResult", diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 7d3e9755..c18137ed 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -5,6 +5,7 @@ from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation, Location +from ..ssh_keys import BoundSSHKey, SSHKey from ..storage_box_types import BoundStorageBoxType, StorageBoxType from .domain import ( CreateStorageBoxResponse, @@ -72,9 +73,9 @@ def get_actions_list( per_page: int | None = None, ) -> ActionsPageResult: """ - Returns all Actions for the Storage Box for a specific page. + Returns a paginated list of Actions for a Storage Box for a specific page. - See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. @@ -96,9 +97,9 @@ def get_actions( sort: list[str] | None = None, ) -> list[BoundAction]: """ - Returns all Actions for the Storage Box. + Returns all Actions for a Storage Box. - See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. @@ -109,7 +110,364 @@ def get_actions( sort=sort, ) - # TODO: implement bound methods + def update( + self, + *, + 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 name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + return self._client.update( + self, + name=name, + labels=labels, + ) + + def delete(self) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + """ + return self._client.delete(self) + + def get_folders( + self, + 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-folders-of-a-storage-box + + :param path: Relative path to list the folders from. + """ + return self._client.get_folders( + self, + path=path, + ) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param delete: Prevents the Storage Box from being deleted. + """ + return self._client.change_protection( + self, + delete=delete, + ) + + def change_type( + self, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box_type: Storage Box Type to change to. + """ + return self._client.change_type( + self, + storage_box_type=storage_box_type, + ) + + def reset_password( + self, + *, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param password: New password. + """ + return self._client.reset_password( + self, + password=password, + ) + + def update_access_settings( + self, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param access_settings: New access settings for the Storage Box. + """ + return self._client.update_access_settings( + self, + access_settings=access_settings, + ) + + def rollback_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param snapshot: Snapshot to rollback to. + """ + return self._client.rollback_snapshot( + self, + snapshot=snapshot, + ) + + def disable_snapshot_plan( + self, + ) -> BoundAction: + """ + Disable the snapshot plan a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + """ + return self._client.disable_snapshot_plan(self) + + def enable_snapshot_plan( + self, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param snapshot_plan: Snapshot Plan to enable. + """ + return self._client.enable_snapshot_plan( + self, + snapshot_plan=snapshot_plan, + ) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param id: ID of the Snapshot. + """ + return self._client.get_snapshot_by_id(self, id=id) + + def get_snapshot_by_name( + self, + name: str, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Name of the Snapshot. + """ + return self._client.get_snapshot_by_name(self, name=name) + + def get_snapshot_list( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_snapshot_list( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def get_snapshot_all( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_snapshot_all( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def create_snapshot( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + return self._client.create_snapshot( + self, + description=description, + labels=labels, + ) + + # Subaccounts + ########################################################################### + def get_subaccount_by_id( + self, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param id: ID of the Subaccount. + """ + return self._client.get_subaccount_by_id(self, id=id) + + def get_subaccount_by_username( + self, + username: str, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: User name of the Subaccount. + """ + return self._client.get_subaccount_by_username(self, username=username) + + def get_subaccount_list( + self, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_subaccount_list( + self, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def get_subaccount_all( + self, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_subaccount_all( + self, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def create_subaccount( + self, + *, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + return self._client.create_subaccount( + self, + home_directory=home_directory, + password=password, + access_settings=access_settings, + description=description, + labels=labels, + ) class BoundStorageBoxSnapshot(BoundModelBase, StorageBoxSnapshot): @@ -141,7 +499,35 @@ def _get_self(self) -> BoundStorageBoxSnapshot: self.data_model.id, ) - # TODO: implement bound methods + def update_snapshot( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + return self._client.update_snapshot( + self, + description=description, + labels=labels, + ) + + def delete_snapshot( + self, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + """ + return self._client.delete_snapshot(self) class BoundStorageBoxSubaccount(BoundModelBase, StorageBoxSubaccount): @@ -173,7 +559,79 @@ def _get_self(self) -> BoundStorageBoxSubaccount: self.data_model.id, ) - # TODO: implement bound methods + def update_subaccount( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + return self._client.update_subaccount( + self, + description=description, + labels=labels, + ) + + def delete_subaccount( + self, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + """ + return self._client.delete_subaccount(self) + + def change_subaccount_home_directory( + self, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param home_directory: Home directory for the Subaccount. + """ + return self._client.change_subaccount_home_directory( + self, home_directory=home_directory + ) + + def reset_subaccount_password( + self, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param password: Password for the Subaccount. + """ + return self._client.reset_subaccount_password(self, password=password) + + def update_subaccount_access_settings( + self, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param access_settings: Access settings for the Subaccount. + """ + return self._client.update_subaccount_access_settings( + self, + access_settings=access_settings, + ) class StorageBoxesPageResult(NamedTuple): @@ -243,7 +701,7 @@ def get_list( per_page: int | None = None, ) -> StorageBoxesPageResult: """ - Returns a list of Storage Boxes for a specific page. + Returns a paginated list of Storage Boxes for a specific page. See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes @@ -298,7 +756,7 @@ def create( password: str, location: BoundLocation | Location, storage_box_type: BoundStorageBoxType | StorageBoxType, - ssh_keys: list[str] | None = None, + ssh_keys: list[str | SSHKey | BoundSSHKey] | None = None, access_settings: StorageBoxAccessSettings | None = None, labels: dict[str, str] | None = None, ) -> CreateStorageBoxResponse: @@ -322,7 +780,10 @@ def create( "storage_box_type": storage_box_type.id_or_name, } if ssh_keys is not None: - data["ssh_keys"] = ssh_keys + data["ssh_keys"] = [ + o.public_key if isinstance(o, (SSHKey, BoundSSHKey)) else o + for o in ssh_keys + ] if access_settings is not None: data["access_settings"] = access_settings.to_payload() if labels is not None: @@ -330,7 +791,7 @@ def create( response = self._client.request( method="POST", - url="/storage_boxes", + url=f"{self._base_url}", json=data, ) @@ -426,7 +887,7 @@ def get_actions_list( per_page: int | None = None, ) -> ActionsPageResult: """ - Returns all Actions for a Storage Box for a specific page. + Returns a paginated list of Actions for a Storage Box for a specific page. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box @@ -578,7 +1039,7 @@ def update_access_settings( def rollback_snapshot( self, storage_box: StorageBox | BoundStorageBox, - snapshot: StorageBoxSnapshot, # TODO: Add BoundStorageBoxSnapshot + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, ) -> BoundAction: """ Rollback the Storage Box to the given snapshot. @@ -733,7 +1194,7 @@ def get_snapshot_all( :param storage_box: Storage Box to get the Snapshots from. :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. - :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. """ @@ -761,7 +1222,7 @@ def create_snapshot( :param storage_box: Storage Box to create a Snapshot from. :param description: Description of the Snapshot. - :param labels: User-defined labels (key/value pairs) for the Resource. + :param labels: User-defined labels (key/value pairs) for the Snapshot. """ data: dict[str, Any] = {} if description is not None: @@ -798,7 +1259,7 @@ def update_snapshot( :param snapshot: Storage Box Snapshot to update. :param description: Description of the Snapshot. - :param labels: User-defined labels (key/value pairs) for the Resource. + :param labels: User-defined labels (key/value pairs) for the Snapshot. """ if snapshot.storage_box is None: raise ValueError("snapshot storage_box property is none") @@ -963,9 +1424,9 @@ def create_subaccount( :param storage_box: Storage Box to create a Subaccount for. :param home_directory: Home directory of the Subaccount. :param password: Password of the Subaccount. - :param access_settings: Access Settings of the Subaccount. + :param access_settings: Access settings of the Subaccount. :param description: Description of the Subaccount. - :param labels: User-defined labels (key/value pairs) for the Resource. + :param labels: User-defined labels (key/value pairs) for the Subaccount. """ data: dict[str, Any] = { "home_directory": home_directory, @@ -1007,7 +1468,7 @@ def update_subaccount( :param subaccount: Storage Box Subaccount to update. :param description: Description of the Subaccount. - :param labels: User-defined labels (key/value pairs) for the Resource. + :param labels: User-defined labels (key/value pairs) for the Subaccount. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 4538db3c..23367c6e 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -57,7 +57,30 @@ def assert_bound_storage_box_subaccount( class TestBoundStorageBox(BoundModelTestCase): - methods = [] + methods = [ + BoundStorageBox.update, + BoundStorageBox.delete, + BoundStorageBox.get_folders, + BoundStorageBox.change_protection, + BoundStorageBox.change_type, + BoundStorageBox.disable_snapshot_plan, + BoundStorageBox.enable_snapshot_plan, + BoundStorageBox.reset_password, + BoundStorageBox.rollback_snapshot, + BoundStorageBox.update_access_settings, + # Snapshots + BoundStorageBox.create_snapshot, + BoundStorageBox.get_snapshot_all, + BoundStorageBox.get_snapshot_by_id, + BoundStorageBox.get_snapshot_by_name, + BoundStorageBox.get_snapshot_list, + # Subaccounts + BoundStorageBox.create_subaccount, + BoundStorageBox.get_subaccount_all, + BoundStorageBox.get_subaccount_by_id, + BoundStorageBox.get_subaccount_by_username, + BoundStorageBox.get_subaccount_list, + ] @pytest.fixture() def resource_client(self, client: Client) -> StorageBoxesClient: @@ -103,7 +126,10 @@ def test_init(self, bound_model: BoundStorageBox, resource_client): class TestBoundStorageBoxSnapshot(BoundModelTestCase): - methods = [] + methods = [ + BoundStorageBoxSnapshot.update_snapshot, + BoundStorageBoxSnapshot.delete_snapshot, + ] @pytest.fixture() def resource_client(self, client: Client) -> StorageBoxesClient: @@ -153,7 +179,13 @@ def test_reload( class TestBoundStorageBoxSubaccount(BoundModelTestCase): - methods = [] + methods = [ + BoundStorageBoxSubaccount.update_subaccount, + BoundStorageBoxSubaccount.delete_subaccount, + BoundStorageBoxSubaccount.change_subaccount_home_directory, + BoundStorageBoxSubaccount.reset_subaccount_password, + BoundStorageBoxSubaccount.update_subaccount_access_settings, + ] @pytest.fixture() def resource_client(self, client: Client) -> StorageBoxesClient: From 8aa48e46fe00aabb23627e88ea2d665ff31ee37e Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 17 Nov 2025 14:12:52 +0100 Subject: [PATCH 14/21] docs: add missing classes --- docs/api.clients.storage_box_types.rst | 5 ++- docs/api.clients.storage_boxes.rst | 56 ++++++++++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst index e0b9ef14..cda5dbc1 100644 --- a/docs/api.clients.storage_box_types.rst +++ b/docs/api.clients.storage_box_types.rst @@ -7,5 +7,8 @@ StorageBoxTypesClient .. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType :members: -.. autoclass:: hcloud.storage_box_types.client.StorageBoxType +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesPageResult + :members: + +.. autoclass:: hcloud.storage_box_types.domain.StorageBoxType :members: diff --git a/docs/api.clients.storage_boxes.rst b/docs/api.clients.storage_boxes.rst index b3710059..93261d6d 100644 --- a/docs/api.clients.storage_boxes.rst +++ b/docs/api.clients.storage_boxes.rst @@ -7,23 +7,65 @@ StorageBoxesClient .. autoclass:: hcloud.storage_boxes.client.BoundStorageBox :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBox +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSnapshot :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotPlan +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSubaccount :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxStats +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxAccessSettings +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotsPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.CreateStorageBoxResponse +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSubaccountsPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.DeleteStorageBoxResponse +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxResponse :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxFoldersResponse +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSubaccountResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSubaccountResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxAccessSettings + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxFoldersResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshot + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotPlan + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotStats + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStats + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStatus + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccount + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccountAccessSettings :members: From 65d94cec61cc262b326c99bb7541455c94a46eff Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 17 Nov 2025 14:50:42 +0100 Subject: [PATCH 15/21] docs: reorder classes --- docs/api.clients.storage_box_types.rst | 4 +-- docs/api.clients.storage_boxes.rst | 48 ++++++++++++++------------ hcloud/storage_boxes/client.py | 1 + 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst index cda5dbc1..9d58506e 100644 --- a/docs/api.clients.storage_box_types.rst +++ b/docs/api.clients.storage_box_types.rst @@ -4,10 +4,10 @@ StorageBoxTypesClient .. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesClient :members: -.. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesPageResult :members: -.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesPageResult +.. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType :members: .. autoclass:: hcloud.storage_box_types.domain.StorageBoxType diff --git a/docs/api.clients.storage_boxes.rst b/docs/api.clients.storage_boxes.rst index 93261d6d..604d9167 100644 --- a/docs/api.clients.storage_boxes.rst +++ b/docs/api.clients.storage_boxes.rst @@ -4,68 +4,72 @@ StorageBoxesClient .. autoclass:: hcloud.storage_boxes.client.StorageBoxesClient :members: -.. autoclass:: hcloud.storage_boxes.client.BoundStorageBox +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSnapshot +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotsPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSubaccount +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSubaccountsPageResult :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxesPageResult +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBox :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotsPageResult +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSnapshot :members: -.. autoclass:: hcloud.storage_boxes.client.StorageBoxSubaccountsPageResult +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSubaccount :members: -.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxResponse + +.. autoclass:: hcloud.storage_boxes.domain.StorageBox :members: -.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSnapshotResponse +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxAccessSettings :members: -.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSubaccountResponse +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotPlan :members: -.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxResponse +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStats :members: -.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSnapshotResponse +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStatus :members: -.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSubaccountResponse + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshot :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBox +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotStats :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxAccessSettings + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccount :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxFoldersResponse +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccountAccessSettings :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshot + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotPlan +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSnapshotResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotStats +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSubaccountResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStats +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxFoldersResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStatus +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccount +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSnapshotResponse :members: -.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccountAccessSettings +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSubaccountResponse :members: diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index c18137ed..e06399a0 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -367,6 +367,7 @@ def create_snapshot( # Subaccounts ########################################################################### + def get_subaccount_by_id( self, id: int, From ff29cc02368a3c69afab727bb1b560ae32cdfefa Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 17 Nov 2025 14:56:54 +0100 Subject: [PATCH 16/21] fix: missing sort for list storage boxes --- hcloud/storage_boxes/client.py | 7 +++++++ tests/unit/storage_boxes/test_client.py | 21 +++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index e06399a0..02cfafae 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -698,6 +698,7 @@ def get_list( self, name: str | None = None, label_selector: str | None = None, + sort: list[str] | None = None, page: int | None = None, per_page: int | None = None, ) -> StorageBoxesPageResult: @@ -708,6 +709,7 @@ def get_list( :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 sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. """ @@ -720,6 +722,8 @@ def get_list( params["page"] = page if per_page is not None: params["per_page"] = per_page + if sort is not None: + params["sort"] = sort response = self._client.request( method="GET", @@ -735,6 +739,7 @@ def get_all( self, name: str | None = None, label_selector: str | None = None, + sort: list[str] | None = None, ) -> list[BoundStorageBox]: """ Returns all Storage Boxes. @@ -743,11 +748,13 @@ def get_all( :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 sort: Sort resources by field and direction. """ return self._iter_pages( self.get_list, name=name, label_selector=label_selector, + sort=sort, ) def create( diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 23367c6e..b954fcbe 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -266,7 +266,10 @@ def test_get_by_id( @pytest.mark.parametrize( "params", [ - {"name": "storage-box1", "page": 1, "per_page": 10}, + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"page": 1, "per_page": 10}, + {"sort": ["id:asc"]}, {}, ], ) @@ -305,7 +308,9 @@ def test_get_list( @pytest.mark.parametrize( "params", [ - {"name": "bx11"}, + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, {}, ], ) @@ -662,6 +667,10 @@ def test_get_snapshot_by_id( "params", [ {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, {}, ], ) @@ -707,6 +716,9 @@ def test_get_snapshot_list( "params", [ {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, {}, ], ) @@ -876,6 +888,9 @@ def test_get_subaccount_by_id( "params", [ {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, {}, ], ) @@ -921,6 +936,8 @@ def test_get_subaccount_list( "params", [ {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, {}, ], ) From 48ca0ce9679fff951cc8a6da3ae9c2604abf4665 Mon Sep 17 00:00:00 2001 From: jo Date: Mon, 17 Nov 2025 15:09:01 +0100 Subject: [PATCH 17/21] test: remove protection from subresources fixtures --- tests/unit/storage_boxes/conftest.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py index 48f1696d..190c53d0 100644 --- a/tests/unit/storage_boxes/conftest.py +++ b/tests/unit/storage_boxes/conftest.py @@ -103,9 +103,6 @@ def storage_box_snapshot1(): "labels": { "key": "value", }, - "protection": { - "delete": False, - }, "created": "2025-11-10T19:16:57Z", "storage_box": 42, } @@ -123,9 +120,6 @@ def storage_box_snapshot2(): "size_filesystem": 0, }, "labels": {}, - "protection": { - "delete": False, - }, "created": "2025-11-10T19:18:57Z", "storage_box": 42, } @@ -149,9 +143,6 @@ def storage_box_subaccount1(): "labels": { "key": "value", }, - "protection": { - "delete": False, - }, "created": "2025-11-10T19:18:57Z", "storage_box": 42, } @@ -173,9 +164,6 @@ def storage_box_subaccount2(): "readonly": False, }, "labels": {}, - "protection": { - "delete": False, - }, "created": "2025-11-10T19:18:57Z", "storage_box": 42, } From d16fa2314bbf804e945b7ca9adad84bcced304c9 Mon Sep 17 00:00:00 2001 From: jo Date: Wed, 3 Dec 2025 14:22:21 +0100 Subject: [PATCH 18/21] fix: do not force kwargs for reset_password --- hcloud/storage_boxes/client.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 02cfafae..1f47d2a8 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -191,7 +191,6 @@ def change_type( def reset_password( self, - *, password: str, ) -> BoundAction: """ @@ -1000,7 +999,6 @@ def change_type( def reset_password( self, storage_box: StorageBox | BoundStorageBox, - *, password: str, ) -> BoundAction: """ From 8aefdf95651da9e6536e618b0d8a6a00265484e1 Mon Sep 17 00:00:00 2001 From: jo Date: Tue, 9 Dec 2025 12:20:26 +0100 Subject: [PATCH 19/21] feat: rename bound model methods --- hcloud/storage_boxes/client.py | 14 ++++++------ tests/unit/conftest.py | 10 +++++++-- tests/unit/storage_boxes/test_client.py | 29 +++++++++++++++++++------ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 1f47d2a8..c0a380b6 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -499,7 +499,7 @@ def _get_self(self) -> BoundStorageBoxSnapshot: self.data_model.id, ) - def update_snapshot( + def update( self, *, description: str | None = None, @@ -519,7 +519,7 @@ def update_snapshot( labels=labels, ) - def delete_snapshot( + def delete( self, ) -> DeleteStorageBoxSnapshotResponse: """ @@ -559,7 +559,7 @@ def _get_self(self) -> BoundStorageBoxSubaccount: self.data_model.id, ) - def update_subaccount( + def update( self, *, description: str | None = None, @@ -579,7 +579,7 @@ def update_subaccount( labels=labels, ) - def delete_subaccount( + def delete( self, ) -> DeleteStorageBoxSubaccountResponse: """ @@ -589,7 +589,7 @@ def delete_subaccount( """ return self._client.delete_subaccount(self) - def change_subaccount_home_directory( + def change_home_directory( self, home_directory: str, ) -> BoundAction: @@ -604,7 +604,7 @@ def change_subaccount_home_directory( self, home_directory=home_directory ) - def reset_subaccount_password( + def reset_password( self, password: str, ) -> BoundAction: @@ -617,7 +617,7 @@ def reset_subaccount_password( """ return self._client.reset_subaccount_password(self, password=password) - def update_subaccount_access_settings( + def update_access_settings( self, access_settings: StorageBoxSubaccountAccessSettings, ) -> BoundAction: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 7c5b5e51..0d2a8ecb 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -170,6 +170,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): class BoundModelTestOptions(TypedDict): sub_resource: bool + client_method: str class BoundModelTestCase: @@ -215,14 +216,19 @@ def test_method( if isinstance(bound_model_method, tuple): bound_model_method, options = bound_model_method + resource_client_method_name = options.get( + "client_method", + bound_model_method.__name__, + ) + # Check if the resource client has a method named after the bound model method. - assert hasattr(resource_client, bound_model_method.__name__) + assert hasattr(resource_client, resource_client_method_name) # Mock the resource client method. resource_client_method_mock = mock.MagicMock() setattr( resource_client, - bound_model_method.__name__, + resource_client_method_name, resource_client_method_mock, ) diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index b954fcbe..0daff945 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -127,8 +127,8 @@ def test_init(self, bound_model: BoundStorageBox, resource_client): class TestBoundStorageBoxSnapshot(BoundModelTestCase): methods = [ - BoundStorageBoxSnapshot.update_snapshot, - BoundStorageBoxSnapshot.delete_snapshot, + (BoundStorageBoxSnapshot.update, {"client_method": "update_snapshot"}), + (BoundStorageBoxSnapshot.delete, {"client_method": "delete_snapshot"}), ] @pytest.fixture() @@ -180,11 +180,26 @@ def test_reload( class TestBoundStorageBoxSubaccount(BoundModelTestCase): methods = [ - BoundStorageBoxSubaccount.update_subaccount, - BoundStorageBoxSubaccount.delete_subaccount, - BoundStorageBoxSubaccount.change_subaccount_home_directory, - BoundStorageBoxSubaccount.reset_subaccount_password, - BoundStorageBoxSubaccount.update_subaccount_access_settings, + ( + BoundStorageBoxSubaccount.update, + {"client_method": "update_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.delete, + {"client_method": "delete_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.change_home_directory, + {"client_method": "change_subaccount_home_directory"}, + ), + ( + BoundStorageBoxSubaccount.reset_password, + {"client_method": "reset_subaccount_password"}, + ), + ( + BoundStorageBoxSubaccount.update_access_settings, + {"client_method": "update_subaccount_access_settings"}, + ), ] @pytest.fixture() From 2f704e644e7168d74cdbb595ce53f238e5291195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Tue, 9 Dec 2025 17:28:25 +0100 Subject: [PATCH 20/21] chore(storage-boxes): add experimental markers to all clients and methods (#604) I added the `Experimental` markers as described in https://hcloud-python.readthedocs.io/en/stable/#experimental-features I saw that for DNS we only marked the methods. The docs specify that classes should also be marked, so I added it to the clients too. --- hcloud/storage_box_types/client.py | 15 +++ hcloud/storage_boxes/client.py | 194 ++++++++++++++++++++++++++++- 2 files changed, 208 insertions(+), 1 deletion(-) diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index c37023c2..05cb03e8 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -25,6 +25,9 @@ class StorageBoxTypesClient(ResourceClientBase): A client for the Storage Box Types API. See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ _base_url = "/storage_box_types" @@ -40,6 +43,9 @@ def get_by_id(self, id: int) -> BoundStorageBoxType: See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-get-a-storage-box-type :param id: ID of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -54,6 +60,9 @@ def get_by_name(self, name: str) -> BoundStorageBoxType | None: See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types :param name: Name of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_list, name=name) @@ -71,6 +80,9 @@ def get_list( :param name: Name of the Storage Box Type. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -102,6 +114,9 @@ def get_all( See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types :param name: Name of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_list, diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index c0a380b6..112d6214 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -81,6 +81,9 @@ def get_actions_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_actions_list( self, @@ -103,6 +106,9 @@ def get_actions( :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_actions( self, @@ -123,6 +129,9 @@ def update( :param name: Name of the Storage Box. :param labels: User-defined labels (key/value pairs) for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update( self, @@ -135,6 +144,9 @@ def delete(self) -> DeleteStorageBoxResponse: Deletes a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete(self) @@ -150,6 +162,9 @@ def get_folders( See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box :param path: Relative path to list the folders from. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_folders( self, @@ -167,6 +182,9 @@ def change_protection( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection :param delete: Prevents the Storage Box from being deleted. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_protection( self, @@ -183,6 +201,9 @@ def change_type( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type :param storage_box_type: Storage Box Type to change to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_type( self, @@ -199,6 +220,9 @@ def reset_password( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password :param password: New password. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.reset_password( self, @@ -215,6 +239,9 @@ def update_access_settings( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings :param access_settings: New access settings for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_access_settings( self, @@ -231,6 +258,9 @@ def rollback_snapshot( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot :param snapshot: Snapshot to rollback to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.rollback_snapshot( self, @@ -244,6 +274,9 @@ def disable_snapshot_plan( Disable the snapshot plan a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.disable_snapshot_plan(self) @@ -257,6 +290,9 @@ def enable_snapshot_plan( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan :param snapshot_plan: Snapshot Plan to enable. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.enable_snapshot_plan( self, @@ -276,6 +312,9 @@ def get_snapshot_by_id( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot :param id: ID of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_by_id(self, id=id) @@ -289,6 +328,9 @@ def get_snapshot_by_name( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots :param name: Name of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_by_name(self, name=name) @@ -309,6 +351,9 @@ def get_snapshot_list( :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_list( self, @@ -335,6 +380,9 @@ def get_snapshot_all( :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_all( self, @@ -357,6 +405,9 @@ def create_snapshot( :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.create_snapshot( self, @@ -377,6 +428,9 @@ def get_subaccount_by_id( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount :param id: ID of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_by_id(self, id=id) @@ -390,6 +444,9 @@ def get_subaccount_by_username( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts :param username: User name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_by_username(self, username=username) @@ -408,6 +465,9 @@ def get_subaccount_list( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_list( self, @@ -431,6 +491,9 @@ def get_subaccount_all( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_all( self, @@ -459,6 +522,9 @@ def create_subaccount( :param access_settings: Access settings of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.create_subaccount( self, @@ -512,6 +578,9 @@ def update( :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_snapshot( self, @@ -526,6 +595,9 @@ def delete( Deletes a Storage Box Snapshot. See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete_snapshot(self) @@ -572,6 +644,9 @@ def update( :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_subaccount( self, @@ -586,6 +661,9 @@ def delete( Deletes a Storage Box Subaccount. See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete_subaccount(self) @@ -599,6 +677,9 @@ def change_home_directory( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory :param home_directory: Home directory for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_subaccount_home_directory( self, home_directory=home_directory @@ -614,6 +695,9 @@ def reset_password( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password :param password: Password for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.reset_subaccount_password(self, password=password) @@ -627,6 +711,9 @@ def update_access_settings( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings :param access_settings: Access settings for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_subaccount_access_settings( self, @@ -654,6 +741,9 @@ class StorageBoxesClient(ResourceClientBase): A client for the Storage Boxes API. See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ _base_url = "/storage_boxes" @@ -676,6 +766,9 @@ def get_by_id(self, id: int) -> BoundStorageBox: See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box :param id: ID of the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -690,6 +783,9 @@ def get_by_name(self, name: str) -> BoundStorageBox | None: See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes :param name: Name of the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_list, name=name) @@ -711,6 +807,9 @@ def get_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -748,6 +847,9 @@ def get_all( :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 sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_list, @@ -779,6 +881,9 @@ def create( :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. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "name": name, @@ -822,6 +927,9 @@ def update( :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. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if name is not None: @@ -847,6 +955,9 @@ def delete( See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box :param storage_box: Storage Box to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="DELETE", @@ -871,6 +982,9 @@ def get_folders( :param storage_box: Storage Box to list the folders from. :param path: Relative path to list the folders from. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if path is not None: @@ -903,6 +1017,9 @@ def get_actions_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if status is not None: @@ -939,6 +1056,9 @@ def get_actions( :param storage_box: Storage Box to fetch the Actions from. :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_actions_list, @@ -960,6 +1080,9 @@ def change_protection( :param storage_box: Storage Box to update. :param delete: Prevents the Storage Box from being deleted. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if delete is not None: @@ -984,6 +1107,9 @@ def change_type( :param storage_box: Storage Box to update. :param storage_box_type: Storage Box Type to change to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "storage_box_type": storage_box_type.id_or_name, @@ -1008,6 +1134,9 @@ def reset_password( :param storage_box: Storage Box to update. :param password: New password. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "password": password, @@ -1032,6 +1161,9 @@ def update_access_settings( :param storage_box: Storage Box to update. :param access_settings: New access settings for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = access_settings.to_payload() @@ -1054,6 +1186,9 @@ def rollback_snapshot( :param storage_box: Storage Box to update. :param snapshot: Snapshot to rollback to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "snapshot": snapshot.id_or_name, @@ -1076,6 +1211,9 @@ def disable_snapshot_plan( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan :param storage_box: Storage Box to update. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="POST", @@ -1095,6 +1233,9 @@ def enable_snapshot_plan( :param storage_box: Storage Box to update. :param snapshot_plan: Snapshot Plan to enable. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = snapshot_plan.to_payload() @@ -1120,6 +1261,9 @@ def get_snapshot_by_id( :param storage_box: Storage Box to get the Snapshot from. :param id: ID of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -1139,6 +1283,9 @@ def get_snapshot_by_name( :param storage_box: Storage Box to get the Snapshot from. :param name: Name of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_snapshot_list, storage_box, name=name) @@ -1158,9 +1305,12 @@ def get_snapshot_list( :param storage_box: Storage Box to get the Snapshots from. :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. - :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -1203,6 +1353,9 @@ def get_snapshot_all( :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ # The endpoint does not have pagination, forward to the list method. result, _ = self.get_snapshot_list( @@ -1229,6 +1382,9 @@ def create_snapshot( :param storage_box: Storage Box to create a Snapshot from. :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if description is not None: @@ -1266,6 +1422,9 @@ def update_snapshot( :param snapshot: Storage Box Snapshot to update. :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if snapshot.storage_box is None: raise ValueError("snapshot storage_box property is none") @@ -1293,6 +1452,9 @@ def delete_snapshot( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot :param snapshot: Storage Box Snapshot to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if snapshot.storage_box is None: raise ValueError("snapshot storage_box property is none") @@ -1320,6 +1482,9 @@ def get_subaccount_by_id( :param storage_box: Storage Box to get the Subaccount from. :param id: ID of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -1339,6 +1504,9 @@ def get_subaccount_by_username( :param storage_box: Storage Box to get the Subaccount from. :param username: User name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by( self.get_subaccount_list, @@ -1363,6 +1531,9 @@ def get_subaccount_list( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if username is not None: @@ -1402,6 +1573,9 @@ def get_subaccount_all( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ # The endpoint does not have pagination, forward to the list method. result, _ = self.get_subaccount_list( @@ -1433,6 +1607,9 @@ def create_subaccount( :param access_settings: Access settings of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "home_directory": home_directory, @@ -1475,6 +1652,9 @@ def update_subaccount( :param subaccount: Storage Box Subaccount to update. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1502,6 +1682,9 @@ def delete_subaccount( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount :param subaccount: Storage Box Subaccount to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1526,6 +1709,9 @@ def change_subaccount_home_directory( :param subaccount: Storage Box Subaccount to update. :param home_directory: Home directory for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1553,6 +1739,9 @@ def reset_subaccount_password( :param subaccount: Storage Box Subaccount to update. :param password: Password for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1580,6 +1769,9 @@ def update_subaccount_access_settings( :param subaccount: Storage Box Subaccount to update. :param access_settings: Access settings for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") From ef588ed7357a7dc6661c780135b8029dfefbc95c Mon Sep 17 00:00:00 2001 From: jo Date: Tue, 9 Dec 2025 18:24:25 +0100 Subject: [PATCH 21/21] wording and kw args only --- hcloud/storage_boxes/client.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 112d6214..0cb3c2db 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -152,6 +152,7 @@ def delete(self) -> DeleteStorageBoxResponse: def get_folders( self, + *, path: str | None = None, ) -> StorageBoxFoldersResponse: """ @@ -271,7 +272,7 @@ def disable_snapshot_plan( self, ) -> BoundAction: """ - Disable the snapshot plan a Storage Box. + Disable the snapshot plan of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan @@ -285,7 +286,7 @@ def enable_snapshot_plan( snapshot_plan: StorageBoxSnapshotPlan, ) -> BoundAction: """ - Enable the snapshot plan a Storage Box. + Enable the snapshot plan of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan @@ -791,6 +792,7 @@ def get_by_name(self, name: str) -> BoundStorageBox | None: def get_list( self, + *, name: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -835,6 +837,7 @@ def get_list( def get_all( self, + *, name: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -971,6 +974,7 @@ def delete( def get_folders( self, storage_box: BoundStorageBox | StorageBox, + *, path: str | None = None, ) -> StorageBoxFoldersResponse: """ @@ -1206,7 +1210,7 @@ def disable_snapshot_plan( storage_box: StorageBox | BoundStorageBox, ) -> BoundAction: """ - Disable the snapshot plan a Storage Box. + Disable the snapshot plan of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan @@ -1227,7 +1231,7 @@ def enable_snapshot_plan( snapshot_plan: StorageBoxSnapshotPlan, ) -> BoundAction: """ - Enable the snapshot plan a Storage Box. + Enable the snapshot plan of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan