From 8c781c32ce27223cc9930228b6c6ce96fad7fd1a Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Wed, 16 Sep 2026 16:30:32 +0200 Subject: [PATCH] feat: list network members Related to https://docs.hetzner.cloud/changelog#2026-09-15-network-members-endpoint --- hcloud/networks/__init__.py | 6 ++ hcloud/networks/client.py | 108 ++++++++++++++++++++++++++++- hcloud/networks/domain.py | 76 +++++++++++++++++++- tests/unit/networks/conftest.py | 24 +++++++ tests/unit/networks/test_client.py | 86 +++++++++++++++++++++++ 5 files changed, 297 insertions(+), 3 deletions(-) diff --git a/hcloud/networks/__init__.py b/hcloud/networks/__init__.py index f49b9934..f0863c51 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -4,6 +4,9 @@ from .domain import ( CreateNetworkResponse, Network, + NetworkMember, + NetworkMemberStatus, + NetworkMemberType, NetworkProtection, NetworkRoute, NetworkSubnet, @@ -18,4 +21,7 @@ "NetworkSubnet", "NetworksClient", "NetworksPageResult", + "NetworkMemberType", + "NetworkMemberStatus", + "NetworkMember", ] diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 73b33e5d..f94595d6 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Literal, NamedTuple from ..actions import ( ActionSort, @@ -11,7 +11,14 @@ ) from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase -from .domain import Network, NetworkRoute, NetworkSubnet +from .domain import ( + Network, + NetworkMember, + NetworkMemberStatus, + NetworkMemberType, + NetworkRoute, + NetworkSubnet, +) if TYPE_CHECKING: from .._client import Client @@ -185,6 +192,27 @@ class NetworksPageResult(NamedTuple): meta: Meta +NetworkMemberSort = Literal[ + "id", + "id:asc", + "id:desc", + "type", + "type:asc", + "type:desc", + "status", + "status:asc", + "status:desc", + "ip", + "ip:asc", + "ip:desc", +] + + +class NetworkMembersPageResult(NamedTuple): + members: list[NetworkMember] + meta: Meta + + class NetworksClient( ResourceClientBaseActionsMixin, ResourceClientBase, @@ -372,6 +400,82 @@ def delete(self, network: Network | BoundNetwork) -> bool: self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") return True + def get_member_list( + self, + network: Network | BoundNetwork, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> NetworkMembersPageResult: + """ + Returns a paginated list of Members for a Network. + + :param network: Network to get the Members for. + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Members returned per page. + """ + params: dict[str, Any] = {} + if type is not None: + params["type"] = type + if status is not None: + params["status"] = status + if subnet is not None: + params["subnet"] = subnet + 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( + url=f"{self._base_url}/{network.id}/members", + method="GET", + params=params, + ) + + return NetworkMembersPageResult( + members=[NetworkMember(**o) for o in response["members"]], + meta=Meta.parse_meta(response), + ) + + def get_member_all( + self, + network: Network | BoundNetwork, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + ) -> list[NetworkMember]: + """ + Returns all of Members for a Network. + + :param network: Network to get the Members for. + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Members returned per page. + """ + return self._iter_pages( + self.get_member_list, + network, + type=type, + status=status, + subnet=subnet, + sort=sort, + ) + def get_actions_list( self, network: Network | BoundNetwork, diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 80c3badc..9fd795e8 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -16,6 +16,9 @@ "NetworkSubnet", "NetworkRoute", "CreateNetworkResponse", + "NetworkMemberType", + "NetworkMemberStatus", + "NetworkMember", ] @@ -180,3 +183,74 @@ def __init__( ): self.network = network self.action = action + + +NetworkMemberType = Literal[ + "server", + "load_balancer", +] + +NetworkMemberStatus = Literal[ + "ok", + "attaching", + "detaching", + "updating", + "error", +] + + +class NetworkMember(BaseDomain): + """Network Member Domain + + :param id: ID of the resource attached to the Network. + :param type: Type of the resource attached to the Network. + :param ip: IP address of the resource within the Network. + :param alias_ips: Additional IP address of the resource within the Network. + :param subnet: IP range of the subnet the resource is attached to. + :param status: Status of the resource within the Network. + """ + + TYPE_SERVER: NetworkMemberType = "server" + """The member is a Server.""" + TYPE_LOAD_BALANCER: NetworkMemberType = "load_balancer" + """The member is a Load Balancer.""" + + STATUS_OK: NetworkMemberStatus = "ok" + """The resource is attached and its network configuration is up to date.""" + STATUS_ATTACHING: NetworkMemberStatus = "attaching" + """The resource is being attached to the Network.""" + STATUS_DETACHING: NetworkMemberStatus = "detaching" + """The resource is being detached from the Network.""" + STATUS_UPDATING: NetworkMemberStatus = "updating" + """The resource network configuration is being updated.""" + STATUS_ERROR: NetworkMemberStatus = "error" + """ + The last operation on this member failed. Its network configuration might be out of date, + and the resource might not be reachable within the Network. + """ + + __api_properties__ = ( + "id", + "type", + "ip", + "alias_ips", + "subnet", + "status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int, + type: NetworkMemberType, + ip: str, + alias_ips: list[str], + subnet: str, + status: NetworkMemberStatus, + ): + self.id = id + self.type = type + self.ip = ip + self.alias_ips = alias_ips + self.subnet = subnet + self.status = status diff --git a/tests/unit/networks/conftest.py b/tests/unit/networks/conftest.py index 69287d21..01b61db9 100644 --- a/tests/unit/networks/conftest.py +++ b/tests/unit/networks/conftest.py @@ -210,3 +210,27 @@ def response_get_actions(): } ] } + + +@pytest.fixture() +def network_member1(): + return { + "type": "server", + "id": 123, + "ip": "10.0.1.2", + "status": "attaching", + "alias_ips": ["10.0.1.20"], + "subnet": "10.0.1.0/24", + } + + +@pytest.fixture() +def network_member2(): + return { + "type": "load_balancer", + "id": 456, + "ip": "10.0.1.3", + "status": "updating", + "alias_ips": [], + "subnet": "10.0.1.0/24", + } diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 3f162b46..60c60e35 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -458,6 +458,92 @@ def test_delete( assert delete_success is True + @pytest.mark.parametrize( + "params", + [ + {"type": ["server"]}, + {"status": ["ok"]}, + {"subnet": ["10.0.1.0/24"]}, + {"sort": ["ip:asc"]}, + {"page": 1, "per_page": 50}, + {}, + ], + ) + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_get_member_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + network_member1, + network_member2, + params, + ): + request_mock.return_value = {"members": [network_member1, network_member2]} + + result = networks_client.get_member_list(network, **params) + + request_mock.assert_called_with( + method="GET", + url="/networks/1/members", + params=params, + ) + + assert result.meta is not None + assert len(result.members) == 2 + + assert result.members[0].id == 123 + assert result.members[0].type == "server" + + assert result.members[0].ip == "10.0.1.2" + assert result.members[0].alias_ips == ["10.0.1.20"] + assert result.members[0].subnet == "10.0.1.0/24" + assert result.members[0].status == "attaching" + + assert result.members[1].id == 456 + assert result.members[1].type == "load_balancer" + + @pytest.mark.parametrize( + "params", + [ + {"type": ["server"]}, + {"status": ["ok"]}, + {"subnet": ["10.0.1.0/24"]}, + {"sort": ["ip:asc"]}, + {}, + ], + ) + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_get_member_all( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + network_member1, + network_member2, + params, + ): + request_mock.return_value = {"members": [network_member1, network_member2]} + + result = networks_client.get_member_all(network, **params) + + request_mock.assert_called_with( + method="GET", + url="/networks/1/members", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert result[0].id == 123 + assert result[0].type == "server" + + assert result[1].id == 456 + assert result[1].type == "load_balancer" + @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] )