From cde61e5808580b3ad83bfa319ca1e9d8dc8b74f7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 6 Mar 2026 01:47:16 +1000 Subject: [PATCH 01/72] feat: parse nested load balancer `label_selector` targets (#633) When a load balancer has a label_selector type target, the API returns a nested "targets" array containing the resolved individual server targets with their health statuses. This data was previously discarded. - Add `targets` parameter to `LoadBalancerTarget.__init__` in domain.py - Parse nested targets in `BoundLoadBalancer.__init__` for label_selector targets, creating `LoadBalancerTarget` objects with server, health_status, type, and use_private_ip fields - Add test fixture and test case for nested target parsing --------- Co-authored-by: Claude Co-authored-by: jo --- hcloud/load_balancers/client.py | 71 ++++++++++++++---------- hcloud/load_balancers/domain.py | 5 ++ tests/unit/load_balancers/conftest.py | 18 +++++- tests/unit/load_balancers/test_client.py | 26 ++++++++- 4 files changed, 90 insertions(+), 30 deletions(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 1986905b..166e86dc 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -93,36 +93,51 @@ def __init__( ] data["private_net"] = private_nets - targets = data.get("targets") - if targets: - tmp_targets = [] - for target in targets: - tmp_target = LoadBalancerTarget(type=target["type"]) - if target["type"] == "server": - tmp_target.server = BoundServer( - client._parent.servers, data=target["server"], complete=False - ) - tmp_target.use_private_ip = target["use_private_ip"] - elif target["type"] == "label_selector": - tmp_target.label_selector = LoadBalancerTargetLabelSelector( - selector=target["label_selector"]["selector"] + def _load_balancer_targets( + raw_targets: list[dict[str, Any]], + ) -> list[LoadBalancerTarget]: + return [_load_balancer_target(raw_target) for raw_target in raw_targets] + + def _load_balancer_target( + raw_target: dict[str, Any], + ) -> LoadBalancerTarget: + result = LoadBalancerTarget(type=raw_target["type"]) + + if raw_target["type"] == "ip": + result.ip = LoadBalancerTargetIP( + ip=raw_target["ip"]["ip"], + ) + + elif raw_target["type"] == "server": + result.server = BoundServer( + client._parent.servers, # pylint: disable=protected-access + data=raw_target["server"], + complete=False, + ) + result.use_private_ip = raw_target["use_private_ip"] + + elif raw_target["type"] == "label_selector": + result.label_selector = LoadBalancerTargetLabelSelector( + selector=raw_target["label_selector"]["selector"] + ) + result.use_private_ip = raw_target["use_private_ip"] + + if (raw_nested_targets := raw_target.get("targets")) is not None: + result.targets = _load_balancer_targets(raw_nested_targets) + + if (raw_health_status := raw_target.get("health_status")) is not None: + result.health_status = [ + LoadBalancerTargetHealthStatus( + listen_port=item["listen_port"], + status=item["status"], ) - tmp_target.use_private_ip = target["use_private_ip"] - elif target["type"] == "ip": - tmp_target.ip = LoadBalancerTargetIP(ip=target["ip"]["ip"]) - - target_health_status = target.get("health_status") - if target_health_status is not None: - tmp_target.health_status = [ - LoadBalancerTargetHealthStatus( - listen_port=target_health_status_item["listen_port"], - status=target_health_status_item["status"], - ) - for target_health_status_item in target_health_status - ] + for item in raw_health_status + ] + + return result - tmp_targets.append(tmp_target) - data["targets"] = tmp_targets + if (raw_targets := data.get("targets")) is not None: + data["targets"] = _load_balancer_targets(raw_targets) services = data.get("services") if services: diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index c02e1100..8bad4ded 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -411,6 +411,8 @@ class LoadBalancerTarget(BaseDomain): use the private IP instead of primary public IP :param health_status: list List of health statuses of the services on this target. Only present for target types "server" and "ip". + :param targets: list + List of resolved label selector targets. Only present for target types "label_selector". """ __api_properties__ = ( @@ -420,6 +422,7 @@ class LoadBalancerTarget(BaseDomain): "ip", "use_private_ip", "health_status", + "targets", ) __slots__ = __api_properties__ @@ -431,6 +434,7 @@ def __init__( ip: LoadBalancerTargetIP | None = None, use_private_ip: bool | None = None, health_status: list[LoadBalancerTargetHealthStatus] | None = None, + targets: list[LoadBalancerTarget] | None = None, ): self.type = type self.server = server @@ -438,6 +442,7 @@ def __init__( self.ip = ip self.use_private_ip = use_private_ip self.health_status = health_status + self.targets = targets def to_payload(self) -> dict[str, Any]: """ diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index f19508ea..8f7f6e6d 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -86,7 +86,23 @@ def response_load_balancer(): "health_status": [{"listen_port": 443, "status": "healthy"}], "label_selector": None, "use_private_ip": False, - } + }, + { + "type": "label_selector", + "label_selector": {"selector": "env=prod"}, + "use_private_ip": True, + "targets": [ + { + "type": "server", + "server": {"id": 105054278}, + "use_private_ip": True, + "health_status": [ + {"listen_port": 443, "status": "healthy"}, + {"listen_port": 3000, "status": "healthy"}, + ], + } + ], + }, ], "algorithm": {"type": "round_robin"}, } diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 7ac3290c..5945b977 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -19,7 +19,7 @@ ) from hcloud.locations import Location from hcloud.networks import Network -from hcloud.servers import Server +from hcloud.servers import BoundServer, Server from ..conftest import BoundModelTestCase @@ -60,6 +60,30 @@ def test_init(self, response_load_balancer): assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" + def test_init_label_selector_nested_targets(self, response_load_balancer): + bound_load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), data=response_load_balancer["load_balancer"] + ) + + label_selector_target = bound_load_balancer.targets[1] + assert label_selector_target.type == "label_selector" + assert label_selector_target.label_selector.selector == "env=prod" + assert label_selector_target.use_private_ip is True + assert label_selector_target.targets is not None + assert len(label_selector_target.targets) == 1 + + nested = label_selector_target.targets[0] + assert nested.type == "server" + assert isinstance(nested.server, BoundServer) + assert nested.server.id == 105054278 + assert nested.use_private_ip is True + assert nested.health_status is not None + assert len(nested.health_status) == 2 + assert nested.health_status[0].listen_port == 443 + assert nested.health_status[0].status == "healthy" + assert nested.health_status[1].listen_port == 3000 + assert nested.health_status[1].status == "healthy" + class TestLoadBalancerslient: @pytest.fixture() From 05ebdaa397ad485e032c7426ddd081160c0fa327 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:35:09 +0100 Subject: [PATCH 02/72] chore(main): release v2.17.0 (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - parse nested load balancer `label_selector` targets (#633) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 6 ++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b5c8f4..12422643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.17.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.0) + +### Features + +- parse nested load balancer `label_selector` targets (#633) + ## [v2.16.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.16.0) ### Storage Boxes support is now generally available diff --git a/hcloud/_version.py b/hcloud/_version.py index aa056a4a..3d1f634a 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.16.0" # x-releaser-pleaser-version +__version__ = "2.17.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 4ae4e5b9..b77498aa 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.16.0", # x-releaser-pleaser-version + version="2.17.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 8458a5269c02f353fa5712ca039bd89257d1d57c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:35:04 +0100 Subject: [PATCH 03/72] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v26.3.0 (#635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [psf/black-pre-commit-mirror](https://redirect.github.com/psf/black-pre-commit-mirror) | repository | minor | `26.1.0` → `26.3.0` | Note: The `pre-commit` manager in Renovate is not supported by the `pre-commit` maintainers or community. Please do not report any problems there, instead [create a Discussion in the Renovate repository](https://redirect.github.com/renovatebot/renovate/discussions/new) if you have any questions. --- ### Release Notes
psf/black-pre-commit-mirror (psf/black-pre-commit-mirror) ### [`v26.3.0`](https://redirect.github.com/psf/black-pre-commit-mirror/compare/26.1.0...26.3.0) [Compare Source](https://redirect.github.com/psf/black-pre-commit-mirror/compare/26.1.0...26.3.0)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/hetznercloud/hcloud-python). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d737c4a9..a5789951 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.1.0 + rev: 26.3.0 hooks: - id: black From 740d169c16feca9f1edae775148c7e53bc080a18 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 23 Mar 2026 11:28:28 +0100 Subject: [PATCH 04/72] fix: missing `__api_properties__` on LoadBalancerService (#639) Closes #638 Added a test to make sure all classes inheriting from BaseDomain implements the `__api_properties__` property. --- hcloud/load_balancers/domain.py | 10 ++++++++++ tests/unit/core/test_domain.py | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 8bad4ded..17ad807e 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -160,6 +160,16 @@ class LoadBalancerService(BaseDomain): Configuration for http/https protocols, required when protocol is http/https """ + __api_properties__ = ( + "protocol", + "listen_port", + "destination_port", + "proxyprotocol", + "health_check", + "http", + ) + __slots__ = __api_properties__ + def __init__( self, protocol: str | None = None, diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index bfef1129..280dc3cb 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -191,3 +191,9 @@ def test_nested_list__eq__(self): d2.child = [ActionDomain(id=2, name="child2")] assert d1 != d2 + + +def test_base_domain_subclasses(): + for c in BaseDomain.__subclasses__(): + assert len(c.__api_properties__) > 0 + assert len(c.__slots__) > 0 From c265773a1a89e34477d1ed2728816598dccf9f17 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:28:45 +0100 Subject: [PATCH 05/72] chore(deps): update dependency pytest-cov to >=7,<7.2 (#637) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b77498aa..f9f36cef 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ "coverage>=7.13,<7.14", "pylint>=4,<4.1", "pytest>=9,<9.1", - "pytest-cov>=7,<7.1", + "pytest-cov>=7,<7.2", "mypy>=1.19,<1.20", "types-python-dateutil", "types-requests", From 63e975f017c35582ca6f959df1597e893b20950a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:29:00 +0100 Subject: [PATCH 06/72] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v26.3.1 (#636) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5789951..4cb38974 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.3.0 + rev: 26.3.1 hooks: - id: black From 335d766f5254dea8b2d91cf404c365a8b189d23c Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:50:01 +0100 Subject: [PATCH 07/72] chore(main): release v2.17.1 (#640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - missing `__api_properties__` on LoadBalancerService (#639) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 6 ++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12422643..29fed689 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.17.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.1) + +### Bug Fixes + +- missing `__api_properties__` on LoadBalancerService (#639) + ## [v2.17.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 3d1f634a..8f665861 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.17.0" # x-releaser-pleaser-version +__version__ = "2.17.1" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index f9f36cef..ccacf936 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.17.0", # x-releaser-pleaser-version + version="2.17.1", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From b573867a1877d00d0aad8db8f5391357ee16384a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:59:25 +0100 Subject: [PATCH 08/72] chore(deps): update dependency mypy to >=1.20,<1.21 (#642) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ccacf936..a92828b2 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.2", - "mypy>=1.19,<1.20", + "mypy>=1.20,<1.21", "types-python-dateutil", "types-requests", ], From 2a187ed70736463c1c017923f0ac026d71ff8bd1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:00:17 +0100 Subject: [PATCH 09/72] chore(deps): update codecov/codecov-action action to v6 (#641) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 379c1fc6..efb3c435 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,6 @@ jobs: if: > !startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'releaser-pleaser--') - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} From 1262feb904e42bac5c619343ac51ec620f8108cb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:01:11 +0100 Subject: [PATCH 10/72] chore(deps): update pypa/gh-action-pypi-publish action to v1.14.0 (#643) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03e44dbb..cc66d19a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,4 +53,4 @@ jobs: path: dist/ - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@v1.14.0 From 8e364412319a8b7513c4d136e28aab057e18f258 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 9 Apr 2026 17:50:21 +0100 Subject: [PATCH 11/72] test: modernize primary-ip tests (#644) Implement the latest testing patterns for the primary ips tests --- tests/unit/primary_ips/conftest.py | 278 +++------------------ tests/unit/primary_ips/test_client.py | 344 ++++++++++++++++---------- 2 files changed, 248 insertions(+), 374 deletions(-) diff --git a/tests/unit/primary_ips/conftest.py b/tests/unit/primary_ips/conftest.py index 4a7b23c6..361de307 100644 --- a/tests/unit/primary_ips/conftest.py +++ b/tests/unit/primary_ips/conftest.py @@ -4,252 +4,56 @@ @pytest.fixture() -def primary_ip_response(): +def primary_ip1(): return { - "primary_ip": { - "assignee_id": 17, - "assignee_type": "server", - "auto_delete": True, - "blocked": False, - "created": "2016-01-30T23:55:00+00:00", - "datacenter": { - "description": "Falkenstein DC Park 8", - "id": 42, - "location": { - "city": "Falkenstein", - "country": "DE", - "description": "Falkenstein DC Park 1", - "id": 1, - "latitude": 50.47612, - "longitude": 12.370071, - "name": "fsn1", - "network_zone": "eu-central", - }, - "name": "fsn1-dc8", - "server_types": { - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - "supported": [1, 2, 3], - }, - }, - "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], - "id": 42, - "ip": "131.232.99.1", - "labels": {}, - "name": "my-resource", - "protection": {"delete": False}, - "type": "ipv4", - } - } - - -@pytest.fixture() -def one_primary_ips_response(): - return { - "meta": { - "pagination": { - "last_page": 4, - "next_page": 4, - "page": 3, - "per_page": 25, - "previous_page": 2, - "total_entries": 100, - } + "id": 42, + "name": "primary-ip1", + "type": "ipv4", + "ip": "131.232.99.1", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "datacenter": { + "id": 4, + "name": "fsn1-dc14", }, - "primary_ips": [ - { - "assignee_id": 17, - "assignee_type": "server", - "auto_delete": True, - "blocked": False, - "created": "2016-01-30T23:55:00+00:00", - "datacenter": { - "description": "Falkenstein DC Park 8", - "id": 42, - "location": { - "city": "Falkenstein", - "country": "DE", - "description": "Falkenstein DC Park 1", - "id": 1, - "latitude": 50.47612, - "longitude": 12.370071, - "name": "fsn1", - "network_zone": "eu-central", - }, - "name": "fsn1-dc8", - "server_types": { - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - "supported": [1, 2, 3], - }, - }, - "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], - "id": 42, - "ip": "131.232.99.1", - "labels": {}, - "name": "my-resource", - "protection": {"delete": False}, - "type": "ipv4", - } - ], - } - - -@pytest.fixture() -def all_primary_ips_response(): - return { - "meta": { - "pagination": { - "last_page": 1, - "next_page": None, - "page": 1, - "per_page": 25, - "previous_page": None, - "total_entries": 1, - } + "location": { + "id": 1, + "name": "fsn1", }, - "primary_ips": [ - { - "assignee_id": 17, - "assignee_type": "server", - "auto_delete": True, - "blocked": False, - "created": "2016-01-30T23:55:00+00:00", - "datacenter": { - "description": "Falkenstein DC Park 8", - "id": 42, - "location": { - "city": "Falkenstein", - "country": "DE", - "description": "Falkenstein DC Park 1", - "id": 1, - "latitude": 50.47612, - "longitude": 12.370071, - "name": "fsn1", - "network_zone": "eu-central", - }, - "name": "fsn1-dc8", - "server_types": { - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - "supported": [1, 2, 3], - }, - }, - "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], - "id": 42, - "ip": "131.232.99.1", - "labels": {}, - "name": "my-resource", - "protection": {"delete": False}, - "type": "ipv4", - } + "dns_ptr": [ + {"dns_ptr": "server.example.com", "ip": "131.232.99.1"}, ], + "labels": {"key": "value"}, + "protection": {"delete": False}, + "created": "2016-01-30T23:55:00Z", } @pytest.fixture() -def primary_ip_create_response(): +def primary_ip2(): return { - "action": { - "command": "create_primary_ip", - "error": {"code": "action_failed", "message": "Action failed"}, - "finished": None, - "id": 13, - "progress": 0, - "resources": [{"id": 17, "type": "server"}], - "started": "2016-01-30T23:50:00+00:00", - "status": "running", + "id": 52, + "name": "primary-ip2", + "type": "ipv4", + "ip": "131.232.99.2", + "assignee_id": None, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "datacenter": { + "id": 4, + "name": "fsn1-dc14", }, - "primary_ip": { - "assignee_id": 17, - "assignee_type": "server", - "auto_delete": True, - "blocked": False, - "created": "2016-01-30T23:50:00+00:00", - "datacenter": { - "description": "Falkenstein DC Park 8", - "id": 42, - "location": { - "city": "Falkenstein", - "country": "DE", - "description": "Falkenstein DC Park 1", - "id": 1, - "latitude": 50.47612, - "longitude": 12.370071, - "name": "fsn1", - "network_zone": "eu-central", - "server_types": { - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - "supported": [1, 2, 3], - }, - }, - "name": "fsn1-dc8", - }, - "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "2001:db8::1"}], - "id": 42, - "ip": "131.232.99.1", - "labels": {"labelkey": "value"}, - "name": "my-ip", - "protection": {"delete": False}, - "type": "ipv4", + "location": { + "id": 1, + "name": "fsn1", }, - } - - -@pytest.fixture() -def response_update_primary_ip(): - return { - "primary_ip": { - "assignee_id": 17, - "assignee_type": "server", - "auto_delete": True, - "blocked": False, - "created": "2016-01-30T23:55:00+00:00", - "datacenter": { - "description": "Falkenstein DC Park 8", - "id": 42, - "location": { - "city": "Falkenstein", - "country": "DE", - "description": "Falkenstein DC Park 1", - "id": 1, - "latitude": 50.47612, - "longitude": 12.370071, - "name": "fsn1", - "network_zone": "eu-central", - }, - "name": "fsn1-dc8", - "server_types": { - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - "supported": [1, 2, 3], - }, - }, - "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], - "id": 42, - "ip": "131.232.99.1", - "labels": {}, - "name": "my-resource", - "protection": {"delete": False}, - "type": "ipv4", - } - } - - -@pytest.fixture() -def response_get_actions(): - return { - "actions": [ - { - "id": 13, - "command": "assign_primary_ip", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - ] + "dns_ptr": [ + {"dns_ptr": "server.example.com", "ip": "131.232.99.1"}, + ], + "labels": {"key": "value"}, + "protection": {"delete": False}, + "created": "2016-01-30T23:55:00Z", } diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index a4ffcc3a..04b5fc10 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -6,9 +6,24 @@ from hcloud import Client from hcloud.datacenters import BoundDatacenter, Datacenter +from hcloud.locations import BoundLocation, Location from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_primary_ip1(o: BoundPrimaryIP, client: PrimaryIPsClient): + assert isinstance(o, BoundPrimaryIP) + assert o._client is client + assert o.id == 42 + assert o.name == "primary-ip1" + + +def assert_bound_primary_ip2(o: BoundPrimaryIP, client: PrimaryIPsClient): + assert isinstance(o, BoundPrimaryIP) + assert o._client is client + assert o.id == 52 + assert o.name == "primary-ip2" class TestBoundPrimaryIP(BoundModelTestCase): @@ -29,87 +44,88 @@ def resource_client(self, client: Client): def bound_model(self, resource_client: PrimaryIPsClient): return BoundPrimaryIP(resource_client, data=dict(id=14)) - def test_init(self, primary_ip_response): - bound_primary_ip = BoundPrimaryIP( - client=mock.MagicMock(), data=primary_ip_response["primary_ip"] - ) + def test_init(self, primary_ip1): + o = BoundPrimaryIP(client=mock.MagicMock(), data=primary_ip1) + + assert o.id == 42 + assert o.name == "primary-ip1" + assert o.ip == "131.232.99.1" + assert o.type == "ipv4" + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert o.blocked is False - assert bound_primary_ip.id == 42 - assert bound_primary_ip.name == "my-resource" - assert bound_primary_ip.ip == "131.232.99.1" - assert bound_primary_ip.type == "ipv4" - assert bound_primary_ip.protection == {"delete": False} - assert bound_primary_ip.labels == {} - assert bound_primary_ip.blocked is False + assert o.assignee_id == 17 + assert o.assignee_type == "server" - assert bound_primary_ip.assignee_id == 17 - assert bound_primary_ip.assignee_type == "server" + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" with pytest.deprecated_call(): - datacenter = bound_primary_ip.datacenter + datacenter = o.datacenter assert isinstance(datacenter, BoundDatacenter) - assert datacenter.id == 42 - assert datacenter.name == "fsn1-dc8" - assert datacenter.description == "Falkenstein DC Park 8" - assert datacenter.location.country == "DE" - assert datacenter.location.city == "Falkenstein" - assert datacenter.location.latitude == 50.47612 - assert datacenter.location.longitude == 12.370071 + assert datacenter.id == 4 + assert datacenter.name == "fsn1-dc14" class TestPrimaryIPsClient: @pytest.fixture() - def primary_ips_client(self, client: Client): + def resource_client(self, client: Client): return PrimaryIPsClient(client) def test_get_by_id( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - primary_ip_response, + resource_client: PrimaryIPsClient, + primary_ip1, ): - request_mock.return_value = primary_ip_response + request_mock.return_value = {"primary_ip": primary_ip1} - bound_primary_ip = primary_ips_client.get_by_id(1) + result = resource_client.get_by_id(1) request_mock.assert_called_with( method="GET", url="/primary_ips/1", ) - assert bound_primary_ip._client is primary_ips_client - assert bound_primary_ip.id == 42 + assert_bound_primary_ip1(result, resource_client) def test_get_by_name( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - one_primary_ips_response, + resource_client: PrimaryIPsClient, + primary_ip1, ): - request_mock.return_value = one_primary_ips_response + request_mock.return_value = {"primary_ips": [primary_ip1]} - bound_primary_ip = primary_ips_client.get_by_name("my-resource") + result = resource_client.get_by_name("primary-ip1") request_mock.assert_called_with( method="GET", url="/primary_ips", - params={"name": "my-resource"}, + params={"name": "primary-ip1"}, ) - assert bound_primary_ip._client is primary_ips_client - assert bound_primary_ip.id == 42 - assert bound_primary_ip.name == "my-resource" + assert_bound_primary_ip1(result, resource_client) - @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) + @pytest.mark.parametrize( + "params", + [ + {"name": "primary-ip1"}, + {"label_selector": "key=value"}, + ], + ) def test_get_all( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - all_primary_ips_response, + resource_client: PrimaryIPsClient, + primary_ip1, + primary_ip2, params, ): - request_mock.return_value = all_primary_ips_response + request_mock.return_value = {"primary_ips": [primary_ip1, primary_ip2]} - bound_primary_ips = primary_ips_client.get_all(**params) + result = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) @@ -119,60 +135,88 @@ def test_get_all( params=params, ) - assert len(bound_primary_ips) == 1 + assert len(result) == 2 + assert_bound_primary_ip1(result[0], resource_client) + assert_bound_primary_ip2(result[1], resource_client) - bound_primary_ip1 = bound_primary_ips[0] + def test_create_with_location( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": None, + } + + result = resource_client.create( + type="ipv4", + name="primary-ip1", + location=Location(name="fsn1"), + ) - assert bound_primary_ip1._client is primary_ips_client - assert bound_primary_ip1.id == 42 - assert bound_primary_ip1.name == "my-resource" + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "primary-ip1", + "type": "ipv4", + "assignee_type": "server", + "location": "fsn1", + "auto_delete": False, + }, + ) + assert_bound_primary_ip1(result.primary_ip, resource_client) + assert result.action is None def test_create_with_datacenter( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - primary_ip_response, + resource_client: PrimaryIPsClient, + primary_ip1, ): - request_mock.return_value = primary_ip_response + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": None, + } with pytest.deprecated_call(): - response = primary_ips_client.create( - type="ipv6", - name="my-resource", - datacenter=Datacenter(name="datacenter"), + result = resource_client.create( + type="ipv4", + name="primary-ip1", + datacenter=Datacenter(name="fsn1-dc14"), ) request_mock.assert_called_with( method="POST", url="/primary_ips", json={ - "name": "my-resource", - "type": "ipv6", + "name": "primary-ip1", + "type": "ipv4", "assignee_type": "server", - "datacenter": "datacenter", + "datacenter": "fsn1-dc14", "auto_delete": False, }, ) - - bound_primary_ip = response.primary_ip - action = response.action - - assert bound_primary_ip._client is primary_ips_client - assert bound_primary_ip.id == 42 - assert bound_primary_ip.name == "my-resource" - assert action is None + assert_bound_primary_ip1(result.primary_ip, resource_client) + assert result.action is None def test_create_with_assignee_id( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - primary_ip_create_response, + resource_client: PrimaryIPsClient, + primary_ip1, + action1_running, ): - request_mock.return_value = primary_ip_create_response - - response = primary_ips_client.create( - type="ipv6", - name="my-ip", + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": action1_running, + } + + result = resource_client.create( + type="ipv4", + name="primary-ip1", assignee_id=17, assignee_type="server", ) @@ -181,161 +225,187 @@ def test_create_with_assignee_id( method="POST", url="/primary_ips", json={ - "name": "my-ip", - "type": "ipv6", + "name": "primary-ip1", + "type": "ipv4", "assignee_id": 17, "assignee_type": "server", "auto_delete": False, }, ) - bound_primary_ip = response.primary_ip - action = response.action - assert bound_primary_ip._client is primary_ips_client - assert bound_primary_ip.id == 42 - assert bound_primary_ip.name == "my-ip" - assert bound_primary_ip.assignee_id == 17 - assert action.id == 13 + assert_bound_primary_ip1(result.primary_ip, resource_client) + assert_bound_action1(result.action, resource_client._parent.actions) @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], ) def test_update( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, + resource_client: PrimaryIPsClient, primary_ip, - response_update_primary_ip, + primary_ip1, ): - request_mock.return_value = response_update_primary_ip + request_mock.return_value = {"primary_ip": primary_ip1} - primary_ip = primary_ips_client.update( - primary_ip, auto_delete=True, name="my-resource" + result = resource_client.update( + primary_ip, + name="changed", + auto_delete=True, ) request_mock.assert_called_with( method="PUT", - url="/primary_ips/1", - json={"auto_delete": True, "name": "my-resource"}, + url="/primary_ips/42", + json={ + "name": "changed", + "auto_delete": True, + }, ) - - assert primary_ip.id == 42 - assert primary_ip.auto_delete is True - assert primary_ip.name == "my-resource" + assert_bound_primary_ip1(result, resource_client) @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], ) - def test_change_protection( + def test_delete( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, + resource_client: PrimaryIPsClient, primary_ip, - action_response, ): - request_mock.return_value = action_response + request_mock.return_value = None - action = primary_ips_client.change_protection(primary_ip, True) + result = resource_client.delete(primary_ip) request_mock.assert_called_with( - method="POST", - url="/primary_ips/1/actions/change_protection", - json={"delete": True}, + method="DELETE", + url="/primary_ips/42", ) - assert action.id == 1 - assert action.progress == 0 + assert result is True @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], ) - def test_delete( + def test_change_protection( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, + resource_client: PrimaryIPsClient, primary_ip, action_response, ): request_mock.return_value = action_response - delete_success = primary_ips_client.delete(primary_ip) + result = resource_client.change_protection( + primary_ip, + delete=True, + ) request_mock.assert_called_with( - method="DELETE", - url="/primary_ips/1", + method="POST", + url="/primary_ips/42/actions/change_protection", + json={ + "delete": True, + }, ) - - assert delete_success is True + assert_bound_action1(result, resource_client._parent.actions) @pytest.mark.parametrize( - "assignee_id,assignee_type,primary_ip", + "primary_ip", [ - (1, "server", PrimaryIP(id=12)), - (1, "server", BoundPrimaryIP(mock.MagicMock(), dict(id=12))), + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), ], ) def test_assign( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - assignee_id, - assignee_type, + resource_client: PrimaryIPsClient, primary_ip, action_response, ): request_mock.return_value = action_response - action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) + result = resource_client.assign( + primary_ip, + assignee_id=17, + assignee_type="server", + ) request_mock.assert_called_with( method="POST", - url="/primary_ips/12/actions/assign", - json={"assignee_id": 1, "assignee_type": "server"}, + url="/primary_ips/42/actions/assign", + json={ + "assignee_id": 17, + "assignee_type": "server", + }, ) - assert action.id == 1 - assert action.progress == 0 + assert_bound_action1(result, resource_client._parent.actions) @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], ) def test_unassign( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, + resource_client: PrimaryIPsClient, primary_ip, action_response, ): request_mock.return_value = action_response - action = primary_ips_client.unassign(primary_ip) + result = resource_client.unassign(primary_ip) request_mock.assert_called_with( method="POST", - url="/primary_ips/12/actions/unassign", + url="/primary_ips/42/actions/unassign", ) - assert action.id == 1 - assert action.progress == 0 + assert_bound_action1(result, resource_client._parent.actions) @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], ) def test_change_dns_ptr( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, + resource_client: PrimaryIPsClient, primary_ip, action_response, ): request_mock.return_value = action_response - action = primary_ips_client.change_dns_ptr( - primary_ip, "1.2.3.4", "server02.example.com" + result = resource_client.change_dns_ptr( + primary_ip, + ip="1.2.3.4", + dns_ptr="server02.example.com", ) request_mock.assert_called_with( method="POST", - url="/primary_ips/12/actions/change_dns_ptr", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, + url="/primary_ips/42/actions/change_dns_ptr", + json={ + "ip": "1.2.3.4", + "dns_ptr": "server02.example.com", + }, ) - assert action.id == 1 - assert action.progress == 0 + assert_bound_action1(result, resource_client._parent.actions) From fb34c3eaa90a9045fdc5c7409ad24057f69c10c3 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:51:06 +0200 Subject: [PATCH 12/72] feat(datacenter, server_type): move available and recommended to server_type (#645) See https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations --------- Co-authored-by: jo --- hcloud/datacenters/domain.py | 30 ++++++++++-- hcloud/server_types/domain.py | 8 ++++ tests/unit/datacenters/test_client.py | 64 ++++++++++++++------------ tests/unit/server_types/conftest.py | 4 ++ tests/unit/server_types/test_client.py | 4 ++ 5 files changed, 77 insertions(+), 33 deletions(-) diff --git a/hcloud/datacenters/domain.py b/hcloud/datacenters/domain.py index 4867915d..bdc7a27b 100644 --- a/hcloud/datacenters/domain.py +++ b/hcloud/datacenters/domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING from ..core import BaseDomain, DomainIdentityMixin @@ -24,8 +25,9 @@ class Datacenter(BaseDomain, DomainIdentityMixin): :param server_types: :class:`DatacenterServerTypes ` """ - __api_properties__ = ("id", "name", "description", "location", "server_types") - __slots__ = __api_properties__ + __properties__ = ("id", "name", "description", "location") + __api_properties__ = (*__properties__, "server_types") + __slots__ = (*__properties__, "_server_types") def __init__( self, @@ -39,7 +41,29 @@ def __init__( self.name = name self.description = description self.location = location - self.server_types = server_types + self._server_types = server_types + + @property + def server_types(self) -> DatacenterServerTypes | None: + """ + .. deprecated:: 2.18.0 + The 'server_types' property is deprecated and will not be supported after 2026-10-01. + Please use 'server_type.locations[]' instead. + + See https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations. + """ + warnings.warn( + "The 'server_types' property is deprecated and will not be supported after 2026-10-01. " + "Please use 'server_type.locations[]' instead. " + "See https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations.", + DeprecationWarning, + stacklevel=2, + ) + return self._server_types + + @server_types.setter + def server_types(self, value: DatacenterServerTypes | None) -> None: + self._server_types = value class DatacenterServerTypes(BaseDomain): diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index 279d928d..cafa873a 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -185,11 +185,15 @@ class ServerTypeLocation(BaseDomain): :param location: Location of the Server Type. :param deprecation: Wether the Server Type is deprecated in this Location. + :param available: Whether the Server Type is currently available in this Location. + :param recommended: Whether the Server Type is currently recommended in this Location. """ __api_properties__ = ( "location", "deprecation", + "available", + "recommended", ) __slots__ = __api_properties__ @@ -198,8 +202,12 @@ def __init__( *, location: BoundLocation, deprecation: dict[str, Any] | None, + available: bool | None, + recommended: bool | None, ): self.location = location self.deprecation = ( DeprecationInfo.from_dict(deprecation) if deprecation is not None else None ) + self.available = available + self.recommended = recommended diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 7f5ca96b..ef1aa840 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -25,36 +25,40 @@ def test_bound_datacenter_init(self, datacenter_response): assert bound_datacenter.location.name == "fsn1" assert bound_datacenter.location.complete is True - assert isinstance(bound_datacenter.server_types, DatacenterServerTypes) - assert len(bound_datacenter.server_types.supported) == 3 - assert bound_datacenter.server_types.supported[0].id == 1 - assert bound_datacenter.server_types.supported[0].complete is False - assert bound_datacenter.server_types.supported[1].id == 2 - assert bound_datacenter.server_types.supported[1].complete is False - assert bound_datacenter.server_types.supported[2].id == 3 - assert bound_datacenter.server_types.supported[2].complete is False - - assert len(bound_datacenter.server_types.available) == 3 - assert bound_datacenter.server_types.available[0].id == 1 - assert bound_datacenter.server_types.available[0].complete is False - assert bound_datacenter.server_types.available[1].id == 2 - assert bound_datacenter.server_types.available[1].complete is False - assert bound_datacenter.server_types.available[2].id == 3 - assert bound_datacenter.server_types.available[2].complete is False - - assert len(bound_datacenter.server_types.available_for_migration) == 3 - assert bound_datacenter.server_types.available_for_migration[0].id == 1 - assert ( - bound_datacenter.server_types.available_for_migration[0].complete is False - ) - assert bound_datacenter.server_types.available_for_migration[1].id == 2 - assert ( - bound_datacenter.server_types.available_for_migration[1].complete is False - ) - assert bound_datacenter.server_types.available_for_migration[2].id == 3 - assert ( - bound_datacenter.server_types.available_for_migration[2].complete is False - ) + with pytest.deprecated_call(): + assert isinstance(bound_datacenter.server_types, DatacenterServerTypes) + assert len(bound_datacenter.server_types.supported) == 3 + assert bound_datacenter.server_types.supported[0].id == 1 + assert bound_datacenter.server_types.supported[0].complete is False + assert bound_datacenter.server_types.supported[1].id == 2 + assert bound_datacenter.server_types.supported[1].complete is False + assert bound_datacenter.server_types.supported[2].id == 3 + assert bound_datacenter.server_types.supported[2].complete is False + + assert len(bound_datacenter.server_types.available) == 3 + assert bound_datacenter.server_types.available[0].id == 1 + assert bound_datacenter.server_types.available[0].complete is False + assert bound_datacenter.server_types.available[1].id == 2 + assert bound_datacenter.server_types.available[1].complete is False + assert bound_datacenter.server_types.available[2].id == 3 + assert bound_datacenter.server_types.available[2].complete is False + + assert len(bound_datacenter.server_types.available_for_migration) == 3 + assert bound_datacenter.server_types.available_for_migration[0].id == 1 + assert ( + bound_datacenter.server_types.available_for_migration[0].complete + is False + ) + assert bound_datacenter.server_types.available_for_migration[1].id == 2 + assert ( + bound_datacenter.server_types.available_for_migration[1].complete + is False + ) + assert bound_datacenter.server_types.available_for_migration[2].id == 3 + assert ( + bound_datacenter.server_types.available_for_migration[2].complete + is False + ) class TestDatacentersClient: diff --git a/tests/unit/server_types/conftest.py b/tests/unit/server_types/conftest.py index 5ff2b5e5..095ab7f4 100644 --- a/tests/unit/server_types/conftest.py +++ b/tests/unit/server_types/conftest.py @@ -41,6 +41,8 @@ def server_type_response(): "id": 1, "name": "nbg1", "deprecation": None, + "available": True, + "recommended": False, }, { "id": 2, @@ -49,6 +51,8 @@ def server_type_response(): "announced": "2023-06-01T00:00:00Z", "unavailable_after": "2023-09-01T00:00:00Z", }, + "available": True, + "recommended": True, }, ], } diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index cdba00f1..b8d266ff 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -33,6 +33,8 @@ def test_init(self, server_type_response): assert o.locations[0].location.id == 1 assert o.locations[0].location.name == "nbg1" assert o.locations[0].deprecation is None + assert o.locations[0].available is True + assert o.locations[0].recommended is False assert o.locations[1].location.id == 2 assert o.locations[1].location.name == "fsn1" assert ( @@ -43,6 +45,8 @@ def test_init(self, server_type_response): o.locations[1].deprecation.unavailable_after.isoformat() == "2023-09-01T00:00:00+00:00" ) + assert o.locations[1].available is True + assert o.locations[1].recommended is True with pytest.deprecated_call(): assert o.deprecated is True From b309d97e9ee198e339f62ab1b543299b43987bfe Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:02:27 +0200 Subject: [PATCH 13/72] chore(main): release v2.18.0 (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Available and recommended Server Types have been moved `Datacenter.server_types` has been deprecated in favor of the new `ServerType.locations[].available` and `ServerType.locations[].recommended` properties. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations) for more details. ### Features - **datacenter, server_type**: move available and recommended to server_type (#645) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ### Available and recommended Server Types have been moved `Datacenter.server_types` has been deprecated in favor of the new `ServerType.locations[].available` and `ServerType.locations[].recommended` properties. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations) for more details. ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 12 ++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fed689..1647669f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v2.18.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.18.0) + +### Available and recommended Server Types have been moved + +`Datacenter.server_types` has been deprecated in favor of the new `ServerType.locations[].available` and `ServerType.locations[].recommended` properties. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations) for more details. + +### Features + +- **datacenter, server_type**: move available and recommended to server_type (#645) + ## [v2.17.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.1) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index 8f665861..ecdea74b 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.17.1" # x-releaser-pleaser-version +__version__ = "2.18.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index a92828b2..00a5d9e5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.17.1", # x-releaser-pleaser-version + version="2.18.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 09b3dcd273b22390def413149e404779789e9a26 Mon Sep 17 00:00:00 2001 From: Lukas Metzner Date: Tue, 28 Apr 2026 13:51:30 +0200 Subject: [PATCH 14/72] feat(primary-ip): `assignee_type` behavior changed when creating a primary ip (#647) In the create Primary IP call, the `assignee_type` argument is now only send when the `assignee_id` argument is set. The `assignee_type` argument will stop defaulting to 'server' in the near future, consider explicitly setting this argument when needed. #### Primary IPs `assignee_type` behavior change As of 1 August 2026, the behavior of the Primary IP `assignee_type` property will change, and will return `unassigned` when the Primary IP is not assigned (when `assignee_id` is `null`). The goal is to eventually assign Primary IPs to other resource types, not only to `server`. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-will-return-unassigned) for more details. In addition, the Primary IP request body `assignee_type` property of the operation [`POST /v1/primary_ips`](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/create_primary_ip) is now optional. Primary IPs created without `assignee_type` return `server` until 1 August 2026, after this date, its value will be `unassigned`. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-make-assignee_type-optional) for more details. Co-authored-by: jo --- hcloud/primary_ips/client.py | 21 +++++++++++++---- tests/unit/primary_ips/test_client.py | 33 +++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 6d6693dc..875032ae 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -311,7 +311,7 @@ def create( name: str, datacenter: Datacenter | BoundDatacenter | None = None, location: Location | BoundLocation | None = None, - assignee_type: str | None = "server", + assignee_type: str | None = None, assignee_id: int | None = None, auto_delete: bool | None = False, labels: dict[str, str] | None = None, @@ -328,13 +328,12 @@ def create( :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) :return: :class:`CreatePrimaryIPResponse ` """ - data: dict[str, Any] = { "name": name, "type": type, - "assignee_type": assignee_type, "auto_delete": auto_delete, } + if datacenter is not None: warnings.warn( "The 'datacenter' argument is deprecated and will be removed after 1 July 2026. " @@ -348,10 +347,24 @@ def create( data["location"] = location.id_or_name if assignee_id is not None: data["assignee_id"] = assignee_id + if assignee_type is None: + assignee_type = "server" + warnings.warn( + "The 'assignee_type' argument will no longer default to 'server' " + "and will be required together with the 'assignee_id' argument. " + "Please explicitly set the 'assignee_type' argument.", + DeprecationWarning, + stacklevel=2, + ) + data["assignee_type"] = assignee_type if labels is not None: data["labels"] = labels - response = self._client.request(url=self._base_url, json=data, method="POST") + response = self._client.request( + method="POST", + url=self._base_url, + json=data, + ) action = None if response.get("action") is not None: diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 04b5fc10..638acb2b 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -162,7 +162,6 @@ def test_create_with_location( json={ "name": "primary-ip1", "type": "ipv4", - "assignee_type": "server", "location": "fsn1", "auto_delete": False, }, @@ -194,7 +193,6 @@ def test_create_with_datacenter( json={ "name": "primary-ip1", "type": "ipv4", - "assignee_type": "server", "datacenter": "fsn1-dc14", "auto_delete": False, }, @@ -236,6 +234,37 @@ def test_create_with_assignee_id( assert_bound_primary_ip1(result.primary_ip, resource_client) assert_bound_action1(result.action, resource_client._parent.actions) + def test_create_with_assignee_type_deprecation( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + action1_running, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": action1_running, + } + + with pytest.deprecated_call(): + resource_client.create( + type="ipv4", + name="primary-ip1", + assignee_id=17, + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "primary-ip1", + "type": "ipv4", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": False, + }, + ) + @pytest.mark.parametrize( "primary_ip", [ From 5dd14c4f05d8fc16235a18cc86267036d25552d0 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:00:21 +0200 Subject: [PATCH 15/72] chore(main): release v2.19.0 (#648) ### Primary IPs `assignee_type` behavior change In the create Primary IP call, the `assignee_type` argument is now only send when the `assignee_id` argument is set. The `assignee_type` argument will stop defaulting to `server` in the near future, consider explicitly setting this argument when needed. As of 1 August 2026, the behavior of the Primary IP `assignee_type` property will change, and will return `unassigned` when the Primary IP is not assigned (when `assignee_id` is `null`). The goal is to eventually assign Primary IPs to other resource types, not only to `server`. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-will-return-unassigned) for more details. In addition, the Primary IP request body `assignee_type` property of the operation [`POST /v1/primary_ips`](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/create_primary_ip) is now optional. Primary IPs created without `assignee_type` return `server` until 1 August 2026, after this date, its value will be `unassigned`. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-make-assignee_type-optional) for more details. ### Features - **primary-ip**: `assignee_type` behavior changed when creating a primary ip (#647) --- CHANGELOG.md | 18 ++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1647669f..050de6be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [v2.19.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.19.0) + +### Primary IPs `assignee_type` behavior change + +In the create Primary IP call, the `assignee_type` argument is now only send when the `assignee_id` argument is set. The `assignee_type` argument will stop defaulting to `server` in the near future, consider explicitly setting this argument when needed. + +As of 1 August 2026, the behavior of the Primary IP `assignee_type` property will change, and will return `unassigned` when the Primary IP is not assigned (when `assignee_id` is `null`). The goal is to eventually assign Primary IPs to other resource types, not only to `server`. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-will-return-unassigned) for more details. + +In addition, the Primary IP request body `assignee_type` property of the operation [`POST /v1/primary_ips`](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/create_primary_ip) is now optional. Primary IPs created without `assignee_type` return `server` until 1 August 2026, after this date, its value will be `unassigned`. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-make-assignee_type-optional) for more details. + +### Features + +- **primary-ip**: `assignee_type` behavior changed when creating a primary ip (#647) + ## [v2.18.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.18.0) ### Available and recommended Server Types have been moved diff --git a/hcloud/_version.py b/hcloud/_version.py index ecdea74b..6704eaaa 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.18.0" # x-releaser-pleaser-version +__version__ = "2.19.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 00a5d9e5..e1df1d54 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.18.0", # x-releaser-pleaser-version + version="2.19.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 1669de263e8ed8176c2f94570176311568003b56 Mon Sep 17 00:00:00 2001 From: Marius Zander Date: Thu, 7 May 2026 15:12:50 +0200 Subject: [PATCH 16/72] feat(load-balancer): support `timeout_idle` http service field (#649) `timeout_idle` controls the time a http connection is allowed to idle before the load balancer drops the connection. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. --- hcloud/load_balancers/client.py | 1 + hcloud/load_balancers/domain.py | 7 ++++++ tests/unit/load_balancers/conftest.py | 5 ++++ tests/unit/load_balancers/test_client.py | 30 ++++++++++++++++++++++-- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 166e86dc..6dd4672e 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -155,6 +155,7 @@ def _load_balancer_target( redirect_http=service["http"]["redirect_http"], cookie_name=service["http"]["cookie_name"], cookie_lifetime=service["http"]["cookie_lifetime"], + timeout_idle=service["http"]["timeout_idle"], ) tmp_service.http.certificates = [ BoundCertificate( diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 17ad807e..7561af2a 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -212,6 +212,8 @@ def to_payload(self) -> dict[str, Any]: http["redirect_http"] = self.http.redirect_http if self.http.sticky_sessions is not None: http["sticky_sessions"] = self.http.sticky_sessions + if self.http.timeout_idle is not None: + http["timeout_idle"] = self.http.timeout_idle http["certificates"] = [ certificate.id for certificate in self.http.certificates or [] @@ -272,6 +274,8 @@ class LoadBalancerServiceHttp(BaseDomain): Redirect traffic from http port 80 to port 443 :param sticky_sessions: bool Use sticky sessions. Only available if protocol is "http" or "https". + :param timeout_idle: int + Idle timeout in seconds for HTTP connections. Must be between 30 and 300 seconds. """ __api_properties__ = ( @@ -280,6 +284,7 @@ class LoadBalancerServiceHttp(BaseDomain): "certificates", "redirect_http", "sticky_sessions", + "timeout_idle", ) __slots__ = __api_properties__ @@ -290,12 +295,14 @@ def __init__( certificates: list[BoundCertificate] | None = None, redirect_http: bool | None = None, sticky_sessions: bool | None = None, + timeout_idle: int | None = None, ): self.cookie_name = cookie_name self.cookie_lifetime = cookie_lifetime self.certificates = certificates self.redirect_http = redirect_http self.sticky_sessions = sticky_sessions + self.timeout_idle = timeout_idle class LoadBalancerHealthCheck(BaseDomain): diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index 8f7f6e6d..d7fe2a90 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -62,6 +62,7 @@ def response_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -155,6 +156,7 @@ def response_create_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -253,6 +255,7 @@ def response_update_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -344,6 +347,7 @@ def response_simple_load_balancers(): "cookie_lifetime": 300, "certificates": [897], "redirect_http": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -428,6 +432,7 @@ def response_simple_load_balancers(): "cookie_lifetime": 300, "certificates": [897], "redirect_http": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 5945b977..d9558f6d 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -13,6 +13,7 @@ LoadBalancerHealthCheck, LoadBalancersClient, LoadBalancerService, + LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, @@ -383,13 +384,38 @@ def test_add_service( ): request_mock.return_value = response_add_service - service = LoadBalancerService(listen_port=80, protocol="http") + service = LoadBalancerService( + listen_port=80, + protocol="http", + destination_port=8080, + proxyprotocol=False, + http=LoadBalancerServiceHttp( + cookie_name="HCLBSTICKY", + cookie_lifetime=300, + redirect_http=True, + sticky_sessions=True, + timeout_idle=60, + ), + ) action = resource_client.add_service(load_balancer, service) request_mock.assert_called_with( method="POST", url="/load_balancers/1/actions/add_service", - json={"protocol": "http", "listen_port": 80}, + json={ + "protocol": "http", + "listen_port": 80, + "destination_port": 8080, + "proxyprotocol": False, + "http": { + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "redirect_http": True, + "sticky_sessions": True, + "timeout_idle": 60, + "certificates": [], + }, + }, ) assert action.id == 13 From 0bbc9dd849300a7f1392393ce6964ace0682de69 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 7 May 2026 16:26:45 +0200 Subject: [PATCH 17/72] chore(main): release v2.20.0 (#651) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Load Balancer HTTP Services now support `timeout_idle` HTTP Services now support the field `timeout_idle`, which controls the time a HTTP connection is allowed to idle before it is being dropped. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. ### Features - **load-balancer**: support `timeout_idle` http service field (#649) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ### Load Balancer HTTP Services now support `timeout_idle` HTTP Services now support the field `timeout_idle`, which controls the time a HTTP connection is allowed to idle before it is being dropped. See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 12 ++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 050de6be..4d4b753d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v2.20.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.20.0) + +### Load Balancer HTTP Services now support `timeout_idle` + +HTTP Services now support the field `timeout_idle`, which controls the time a HTTP connection is allowed to idle before it is being dropped. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. + +### Features + +- **load-balancer**: support `timeout_idle` http service field (#649) + ## [v2.19.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.19.0) ### Primary IPs `assignee_type` behavior change diff --git a/hcloud/_version.py b/hcloud/_version.py index 6704eaaa..f4f40e66 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.19.0" # x-releaser-pleaser-version +__version__ = "2.20.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index e1df1d54..618ac950 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.19.0", # x-releaser-pleaser-version + version="2.20.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 841dff907beffddcb37436876995898c5dd82e38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 11:31:53 +0200 Subject: [PATCH 18/72] chore(deps): update dependency mypy to v2 (#650) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 618ac950..67140c77 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.2", - "mypy>=1.20,<1.21", + "mypy>=2.1,<2.2", "types-python-dateutil", "types-requests", ], From 940a30fb16aeede05d38e4eb1549604fdfb370cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 15:08:54 +0200 Subject: [PATCH 19/72] chore(deps): update dependency coverage to >=7.14,<7.15 (#652) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 67140c77..16983147 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.13,<7.14", + "coverage>=7.14,<7.15", "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.2", From 7778d20aff78dec27b6b8a4954336869c47f4565 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 14:48:26 +0200 Subject: [PATCH 20/72] chore(deps): update dependency myst-parser to >=5,<5.2 (#653) --- docs/conf.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index df08f92c..7992a6ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,7 @@ add_module_names = False # Myst Parser -myst_enable_extensions = ["colon_fence"] +myst_enable_extensions = ["colon_fence", "alert"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output diff --git a/setup.py b/setup.py index 16983147..a36ab35e 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ "docs": [ "sphinx>=9,<9.2", "sphinx-rtd-theme>=3,<3.2", - "myst-parser>=5,<5.1", + "myst-parser>=5,<5.2", "watchdog>=6,<6.1", ], "test": [ From cc39d444b37d3341335a10508b47280dc888b5ad Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 13:13:02 +0200 Subject: [PATCH 21/72] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v26.5.1 (#654) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4cb38974..27bc959c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.3.1 + rev: 26.5.1 hooks: - id: black From d52319c40e5b1b901bcaf9c443da07fdcaad9bbc Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 8 Jun 2026 11:18:06 +0200 Subject: [PATCH 22/72] chore: exclude dns migration links from checker (#657) --- .github/workflows/links.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index cf99d202..b707d7ee 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -35,5 +35,7 @@ jobs: --exclude 'https://docs.hetzner.cloud/reference/hetzner#' --exclude 'codecov.io' --exclude 'github.com' + --exclude 'https://docs.hetzner.com/networking/dns/faq/beta/' + --exclude 'https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process/' '**/*.md' '**/*.py' From 78301e010542b57df8c9bb6df5c492b120adfa22 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 11:18:34 +0200 Subject: [PATCH 23/72] chore(deps): pin dependencies (#655) --- .github/workflows/links.yml | 6 +++--- .github/workflows/lint.yml | 8 ++++---- .github/workflows/release.yml | 10 +++++----- .github/workflows/releaser-pleaser.yml | 2 +- .github/workflows/test.yml | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index b707d7ee..484e9626 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -11,15 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/cache@v5 + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: .lycheecache key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- - - uses: lycheeverse/lychee-action@v2 + - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 with: fail: true args: > diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d0a893a0..1686f3ec 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,10 +9,10 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: 3.x @@ -25,10 +25,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: 3.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc66d19a..d195d05f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: 3.x @@ -29,7 +29,7 @@ jobs: - name: Upload packages artifact if: github.event_name == 'release' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-packages path: dist/ @@ -47,10 +47,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: python-packages path: dist/ - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@v1.14.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index ebbf620c..7e552517 100644 --- a/.github/workflows/releaser-pleaser.yml +++ b/.github/workflows/releaser-pleaser.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: releaser-pleaser - uses: apricote/releaser-pleaser@v0.8.0 + uses: apricote/releaser-pleaser@a1ce9493fd3f3abe60f22c37249d257bc10081dc # v0.8.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index efb3c435..e40fa076 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,10 +20,10 @@ jobs: name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 with: python-version: ${{ matrix.python-version }} @@ -37,6 +37,6 @@ jobs: if: > !startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'releaser-pleaser--') - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 with: token: ${{ secrets.CODECOV_TOKEN }} From 36f530260feb7f4e724731c65c956cb12a3a02d9 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 12 Jun 2026 14:34:59 +0200 Subject: [PATCH 24/72] feat: retry requests on api bad_gateway error (#658) If the API returns an error code `bad_gateway`, retry the failed request. --- hcloud/_client.py | 2 ++ tests/unit/test_client.py | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/hcloud/_client.py b/hcloud/_client.py index 20f26c9d..fe37dc98 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -129,6 +129,7 @@ class Client: - ``conflict`` - ``rate_limit_exceeded`` + - ``bad_gateway`` - ``timeout`` Changes to the retry policy might occur between releases, and will not be considered @@ -420,6 +421,7 @@ def _retry_policy(self, exception: APIException) -> bool: return exception.code in ( "rate_limit_exceeded", "conflict", + "bad_gateway", "timeout", ) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 41094a88..460a25da 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -106,6 +106,10 @@ def test_init(self, client: ClientBase): APIException(code="conflict", message="Error", details=None), True, ), + ( + APIException(code="bad_gateway", message="Error", details=None), + True, + ), ( APIException(code=409, message="Conflict", details=None), False, From 32ea380d2c04e8c5b7d07572babb3348f1e4d800 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:37:44 +0200 Subject: [PATCH 25/72] chore(main): release v2.21.0 (#659) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - retry requests on api bad_gateway error (#658) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 6 ++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d4b753d..9fe8eb32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.21.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.21.0) + +### Features + +- retry requests on api bad_gateway error (#658) + ## [v2.20.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.20.0) ### Load Balancer HTTP Services now support `timeout_idle` diff --git a/hcloud/_version.py b/hcloud/_version.py index f4f40e66..e5527186 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.20.0" # x-releaser-pleaser-version +__version__ = "2.21.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index a36ab35e..f2a279bf 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.20.0", # x-releaser-pleaser-version + version="2.21.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From b4a25976bd8ab5a41e20700ea6bca11ee06e401f Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 18 Jun 2026 12:00:28 +0200 Subject: [PATCH 26/72] ci: update read the docs build config (#661) Bump the os version and python version used to build our documentation. --- .readthedocs.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index dcda10d7..e34d455d 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -7,9 +7,9 @@ version: 2 # Set the OS, Python version and other tools you might need build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: - python: "3.11" + python: "3.14" # Build documentation in the "docs/" directory with Sphinx sphinx: From 9e1883a54627026d55fcac718a356ada2dc65103 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 18 Jun 2026 15:34:32 +0200 Subject: [PATCH 27/72] feat: deprecate datacenters (#656) The API endpoints `GET /v1/datacenters` and `GET /v1/datacenters/{id}` are now deprecated and will be removed after 1 Oct. 2026. After this date, requests to these endpoints will return `HTTP 410 Gone`. See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. --- hcloud/_client.py | 30 ++++++++++++++++++++---- hcloud/core/client.py | 5 +++- hcloud/datacenters/client.py | 33 ++++++++++++++++++++++++++- hcloud/datacenters/domain.py | 14 ++++++++++++ hcloud/primary_ips/client.py | 4 +++- hcloud/servers/client.py | 4 +++- tests/unit/actions/test_client.py | 14 ++++++++---- tests/unit/datacenters/test_client.py | 7 +++++- tests/unit/datacenters/test_domain.py | 4 ++++ 9 files changed, 102 insertions(+), 13 deletions(-) diff --git a/hcloud/_client.py b/hcloud/_client.py index fe37dc98..4a71bb0b 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import time +import warnings from http import HTTPStatus from random import uniform from typing import Any, Protocol @@ -181,11 +182,10 @@ def __init__( timeout=timeout, ) - self.datacenters = DatacentersClient(self) - """DatacentersClient Instance + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + self.datacenters = DatacentersClient(self) - :type: :class:`DatacentersClient ` - """ self.locations = LocationsClient(self) """LocationsClient Instance @@ -303,6 +303,28 @@ def request( # type: ignore[no-untyped-def] """ return self._client.request(method, url, **kwargs) + @property + def datacenters(self) -> DatacentersClient: + """DatacentersClient Instance + + .. deprecated:: 2.22.0 + The datacenters client is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + + :type: :class:`DatacentersClient ` + """ + warnings.warn( + "The datacenters client is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) + return self._datacenters + + @datacenters.setter + def datacenters(self, value: DatacentersClient) -> None: + self._datacenters = value + class ClientBase: def __init__( diff --git a/hcloud/core/client.py b/hcloud/core/client.py index 4ff7f240..45f5039c 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -109,7 +109,10 @@ def __init__( """ self._client = client self.complete = complete - self.data_model: Domain = self.model.from_dict(data) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + self.data_model: Domain = self.model.from_dict(data) def __getattr__(self, name: str): # type: ignore[no-untyped-def] """Allow magical access to the properties of the model diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 5d7fe964..f8a7f3b0 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,12 +1,16 @@ from __future__ import annotations -from typing import Any, NamedTuple +import warnings +from typing import TYPE_CHECKING, Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from ..server_types import BoundServerType from .domain import Datacenter, DatacenterServerTypes +if TYPE_CHECKING: + from .._client import Client + __all__ = [ "BoundDatacenter", "DatacentersPageResult", @@ -15,6 +19,12 @@ class BoundDatacenter(BoundModelBase[Datacenter], Datacenter): + """ + .. deprecated:: 2.22.0 + The bound datacenter class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + _client: DatacentersClient model = Datacenter @@ -54,13 +64,34 @@ def __init__(self, client: DatacentersClient, data: dict[str, Any]): class DatacentersPageResult(NamedTuple): + """ + .. deprecated:: 2.22.0 + The datacenters page result class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + datacenters: list[BoundDatacenter] meta: Meta class DatacentersClient(ResourceClientBase): + """ + .. deprecated:: 2.22.0 + The datacenters client class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + _base_url = "/datacenters" + def __init__(self, client: Client): + warnings.warn( + "The datacenters client class is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(client) + def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. diff --git a/hcloud/datacenters/domain.py b/hcloud/datacenters/domain.py index bdc7a27b..aef7d700 100644 --- a/hcloud/datacenters/domain.py +++ b/hcloud/datacenters/domain.py @@ -18,6 +18,10 @@ class Datacenter(BaseDomain, DomainIdentityMixin): """Datacenter Domain + .. deprecated:: 2.22.0 + The datacenters domain class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + :param id: int ID of Datacenter :param name: str Name of Datacenter :param description: str Description of Datacenter @@ -37,6 +41,12 @@ def __init__( location: Location | None = None, server_types: DatacenterServerTypes | None = None, ): + warnings.warn( + "The datacenter domain class is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) self.id = id self.name = name self.description = description @@ -69,6 +79,10 @@ def server_types(self, value: DatacenterServerTypes | None) -> None: class DatacenterServerTypes(BaseDomain): """DatacenterServerTypes Domain + .. deprecated:: 2.22.0 + The datacenters domain class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + :param available: List[:class:`BoundServerTypes `] All available server types for this datacenter :param supported: List[:class:`BoundServerTypes `] diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 875032ae..4a0c490d 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -44,7 +44,9 @@ def __init__( raw = data.get("datacenter", {}) if raw: - data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) raw = data.get("location", {}) if raw: diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 1128f420..6e406f2c 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -77,7 +77,9 @@ def __init__( ): raw = data.get("datacenter") if raw: - data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) raw = data.get("location") if raw: diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 2b9d8113..26930de4 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -1,6 +1,7 @@ from __future__ import annotations import inspect +import warnings from unittest import mock import pytest @@ -47,10 +48,15 @@ def test_resources_with_actions(client: Client): """ Ensure that the list of resource clients above is up to date. """ - members = inspect.getmembers( - client, - predicate=lambda p: isinstance(p, ResourceClientBase) and hasattr(p, "actions"), - ) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + + members = inspect.getmembers( + client, + predicate=lambda p: isinstance(p, ResourceClientBase) + and hasattr(p, "actions"), + ) + for name, member in members: assert name in resources_with_actions diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index ef1aa840..59d0d25b 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from unittest import mock # noqa: F401 import pytest # noqa: F401 @@ -8,6 +9,8 @@ from hcloud.datacenters import BoundDatacenter, DatacentersClient, DatacenterServerTypes from hcloud.locations import BoundLocation +warnings.filterwarnings("ignore", category=DeprecationWarning) + class TestBoundDatacenter: def test_bound_datacenter_init(self, datacenter_response): @@ -64,7 +67,9 @@ def test_bound_datacenter_init(self, datacenter_response): class TestDatacentersClient: @pytest.fixture() def datacenters_client(self, client: Client): - return DatacentersClient(client) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return DatacentersClient(client) def test_get_by_id( self, diff --git a/tests/unit/datacenters/test_domain.py b/tests/unit/datacenters/test_domain.py index 58666bf6..fbf2b156 100644 --- a/tests/unit/datacenters/test_domain.py +++ b/tests/unit/datacenters/test_domain.py @@ -1,9 +1,13 @@ from __future__ import annotations +import warnings + import pytest from hcloud.datacenters import Datacenter, DatacenterServerTypes +warnings.filterwarnings("ignore", category=DeprecationWarning) + @pytest.mark.parametrize( "value", From 6fdc2d73e7aaa58acdb6b3dcaf6b461154d908d5 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 18 Jun 2026 17:32:36 +0200 Subject: [PATCH 28/72] chore(main): release v2.22.0 (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Datacenters resource is now deprecated The endpoints `GET /v1/datacenters` and `GET /v1/datacenters/{id}` are now deprecated and will be removed after 1 Oct. 2026. After this date, requests to these endpoints will return `HTTP 410 Gone`. The `DatacentersClient`, `Datacenter` and related classes are now deprecated. See the [changelog](https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated) for more details. ### Features - deprecate datacenters (#656) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ### Datacenters resource is now deprecated The endpoints `GET /v1/datacenters` and `GET /v1/datacenters/{id}` are now deprecated and will be removed after 1 Oct. 2026. After this date, requests to these endpoints will return `HTTP 410 Gone`. The `DatacentersClient`, `Datacenter` and related classes are now deprecated. See the [changelog](https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated) for more details. ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 14 ++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe8eb32..6d40b4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [v2.22.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.22.0) + +### Datacenters resource is now deprecated + +The endpoints `GET /v1/datacenters` and `GET /v1/datacenters/{id}` are now deprecated and will be removed after 1 Oct. 2026. After this date, requests to these endpoints will return `HTTP 410 Gone`. + +The `DatacentersClient`, `Datacenter` and related classes are now deprecated. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated) for more details. + +### Features + +- deprecate datacenters (#656) + ## [v2.21.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.21.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index e5527186..7576cdbd 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.21.0" # x-releaser-pleaser-version +__version__ = "2.22.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index f2a279bf..768aaa41 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.21.0", # x-releaser-pleaser-version + version="2.22.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 3c2e97243cfcb0441c424566914cf30ea9bb38ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:11:45 +0200 Subject: [PATCH 29/72] chore(deps): update actions/cache action to v6 (#667) --- .github/workflows/links.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 484e9626..321e991a 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .lycheecache key: cache-lychee-${{ github.sha }} From 557e5a1371934900c45dce4b681747bdbc22ddf6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:14:25 +0200 Subject: [PATCH 30/72] chore(deps): update actions/setup-python digest to ece7cb0 (#665) --- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1686f3ec..4c8e3094 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d195d05f..240a89c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e40fa076..1f631995 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} From 24533d0b976b32df9f310aacdd23fad9d2e7fde3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:16:40 +0200 Subject: [PATCH 31/72] chore(deps): update actions/checkout action to v7 (#666) --- .github/workflows/links.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 321e991a..401746bd 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4c8e3094..f1910d8d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 @@ -25,7 +25,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 240a89c6..08d3de84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f631995..325a0e5f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 From 725b093ac4de8df3df680401a174ce7ce6371ceb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:16:54 +0200 Subject: [PATCH 32/72] chore(deps): update codecov/codecov-action action to v7 (#664) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 325a0e5f..90181486 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,6 @@ jobs: if: > !startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'releaser-pleaser--') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} From 85abb34f9e4e66559284478f11d1f288fe49f49e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:17:05 +0200 Subject: [PATCH 33/72] chore(deps): update dependency pytest to >=9,<9.2 (#663) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 768aaa41..28cc7325 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ "test": [ "coverage>=7.14,<7.15", "pylint>=4,<4.1", - "pytest>=9,<9.1", + "pytest>=9,<9.2", "pytest-cov>=7,<7.2", "mypy>=2.1,<2.2", "types-python-dateutil", From 36656a585f3ffdee19c99a7e61c4cb3594a6e4bf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:13:22 +0200 Subject: [PATCH 34/72] chore(deps): update github actions (#671) --- .github/workflows/links.yml | 2 +- .github/workflows/releaser-pleaser.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 401746bd..2791863e 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -19,7 +19,7 @@ jobs: key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- - - uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2 + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: fail: true args: > diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 7e552517..e810632f 100644 --- a/.github/workflows/releaser-pleaser.yml +++ b/.github/workflows/releaser-pleaser.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: releaser-pleaser - uses: apricote/releaser-pleaser@a1ce9493fd3f3abe60f22c37249d257bc10081dc # v0.8.0 + uses: apricote/releaser-pleaser@93a3e2c401320206dcd9a9b7a5bbc0850072b3c6 # v0.9.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From e29b12fb0f1e4f337e9d4e8a863ba43a44f47a95 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:22:20 +0200 Subject: [PATCH 35/72] chore(deps): update dependency mypy to >=2.2,<2.3 (#670) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 28cc7325..3e3e4315 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", - "mypy>=2.1,<2.2", + "mypy>=2.2,<2.3", "types-python-dateutil", "types-requests", ], From b733181f4cac54ddcbbca119e8018e631139ada1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:22:33 +0200 Subject: [PATCH 36/72] chore(deps): update dependency coverage to >=7.15,<7.16 (#669) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3e3e4315..ff037d56 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.14,<7.15", + "coverage>=7.15,<7.16", "pylint>=4,<4.1", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", From abdadf3e7cc5d0edeb38f369955fa83f71b102e5 Mon Sep 17 00:00:00 2001 From: Lukas Metzner Date: Thu, 16 Jul 2026 08:54:38 +0200 Subject: [PATCH 37/72] feat: remove datacenter property from server and primary_ip (#668) Remove the `datacenter` property from Server and Primary IP resource where possible. The property was deprecated ~6 months ago, and because the property is removed from the API, removing it from our code is not considered a breaking change. Changelog entry: https://docs.hetzner.cloud/changelog#2026-07-01-removing-datacenters --- hcloud/primary_ips/client.py | 19 ----- hcloud/primary_ips/domain.py | 41 +-------- hcloud/servers/client.py | 20 ----- hcloud/servers/domain.py | 39 +-------- tests/unit/primary_ips/conftest.py | 8 -- tests/unit/primary_ips/test_client.py | 39 --------- tests/unit/servers/conftest.py | 114 -------------------------- tests/unit/servers/test_client.py | 49 ----------- 8 files changed, 4 insertions(+), 325 deletions(-) diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 4a0c490d..08efdf34 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: from .._client import Client - from ..datacenters import BoundDatacenter, Datacenter from ..locations import BoundLocation, Location @@ -39,15 +38,8 @@ def __init__( complete: bool = True, ): # pylint: disable=import-outside-toplevel - from ..datacenters import BoundDatacenter from ..locations import BoundLocation - raw = data.get("datacenter", {}) - if raw: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) - raw = data.get("location", {}) if raw: data["location"] = BoundLocation(client._parent.locations, raw) @@ -311,7 +303,6 @@ def create( self, type: str, name: str, - datacenter: Datacenter | BoundDatacenter | None = None, location: Location | BoundLocation | None = None, assignee_type: str | None = None, assignee_id: int | None = None, @@ -322,7 +313,6 @@ def create( :param type: str Primary IP type Choices: ipv4, ipv6 :param name: str - :param datacenter: Datacenter (optional) :param location: Location (optional) :param assignee_type: str (optional) :param assignee_id: int (optional) @@ -336,15 +326,6 @@ def create( "auto_delete": auto_delete, } - if datacenter is not None: - warnings.warn( - "The 'datacenter' argument is deprecated and will be removed after 1 July 2026. " - "Please use the 'location' argument instead. " - "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters", - DeprecationWarning, - stacklevel=2, - ) - data["datacenter"] = datacenter.id_or_name if location is not None: data["location"] = location.id_or_name if assignee_id is not None: diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index 96749be2..2e964498 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -1,13 +1,11 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction - from ..datacenters import BoundDatacenter from ..locations import BoundLocation from ..rdns import DNSPtr from .client import BoundPrimaryIP @@ -30,14 +28,6 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): Type of Primary IP. Choices: `ipv4`, `ipv6` :param dns_ptr: List[Dict] Array of reverse DNS entries - :param datacenter: :class:`Datacenter ` - Datacenter the Primary IP was created in. - - This property is deprecated and will be removed after 1 July 2026. - Please use the ``location`` property instead. - - See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters. - :param location: :class:`Location ` Location the Primary IP was created in. :param blocked: boolean @@ -58,7 +48,7 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): Delete the Primary IP when the Assignee it is assigned to is deleted. """ - __properties__ = ( + __api_properties__ = ( "id", "ip", "type", @@ -73,14 +63,7 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): "assignee_type", "auto_delete", ) - __api_properties__ = ( - *__properties__, - "datacenter", - ) - __slots__ = ( - *__properties__, - "_datacenter", - ) + __slots__ = __api_properties__ def __init__( self, @@ -88,7 +71,6 @@ def __init__( type: str | None = None, ip: str | None = None, dns_ptr: list[DNSPtr] | None = None, - datacenter: BoundDatacenter | None = None, location: BoundLocation | None = None, blocked: bool | None = None, protection: PrimaryIPProtection | None = None, @@ -103,7 +85,6 @@ def __init__( self.type = type self.ip = ip self.dns_ptr = dns_ptr - self.datacenter = datacenter self.location = location self.blocked = blocked self.protection = protection @@ -114,24 +95,6 @@ def __init__( self.assignee_type = assignee_type self.auto_delete = auto_delete - @property - def datacenter(self) -> BoundDatacenter | None: - """ - :meta private: - """ - warnings.warn( - "The 'datacenter' property is deprecated and will be removed after 1 July 2026. " - "Please use the 'location' property instead. " - "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters.", - DeprecationWarning, - stacklevel=2, - ) - return self._datacenter - - @datacenter.setter - def datacenter(self, value: BoundDatacenter | None) -> None: - self._datacenter = value - class PrimaryIPProtection(TypedDict): delete: bool diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 6e406f2c..68cbb9ce 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,6 +1,5 @@ from __future__ import annotations -import warnings from datetime import datetime from typing import TYPE_CHECKING, Any, NamedTuple @@ -15,7 +14,6 @@ ) from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase -from ..datacenters import BoundDatacenter from ..firewalls import BoundFirewall from ..floating_ips import BoundFloatingIP from ..images import BoundImage, CreateImageResponse @@ -44,7 +42,6 @@ if TYPE_CHECKING: from .._client import Client - from ..datacenters import Datacenter from ..firewalls import Firewall from ..images import Image from ..isos import Iso @@ -75,12 +72,6 @@ def __init__( data: dict[str, Any], complete: bool = True, ): - raw = data.get("datacenter") - if raw: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) - raw = data.get("location") if raw: data["location"] = BoundLocation(client._parent.locations, raw) @@ -636,7 +627,6 @@ def create( user_data: str | None = None, labels: dict[str, str] | None = None, location: Location | BoundLocation | None = None, - datacenter: Datacenter | BoundDatacenter | None = None, start_after_create: bool | None = True, automount: bool | None = None, placement_group: PlacementGroup | BoundPlacementGroup | None = None, @@ -661,7 +651,6 @@ def create( :param labels: Dict[str,str] (optional) User-defined labels (key-value pairs) :param location: :class:`BoundLocation ` or :class:`Location ` - :param datacenter: :class:`BoundDatacenter ` or :class:`Datacenter ` :param start_after_create: boolean (optional) Start Server right after creation. Defaults to True. :param automount: boolean (optional) @@ -681,15 +670,6 @@ def create( if location is not None: data["location"] = location.id_or_name - if datacenter is not None: - warnings.warn( - "The 'datacenter' argument is deprecated and will be removed after 1 July 2026. " - "Please use the 'location' argument instead. " - "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters", - DeprecationWarning, - stacklevel=2, - ) - data["datacenter"] = datacenter.id_or_name if ssh_keys is not None: data["ssh_keys"] = [ssh_key.id_or_name for ssh_key in ssh_keys] if volumes is not None: diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index ec019e4e..32ea4506 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,13 +1,11 @@ from __future__ import annotations -import warnings from typing import TYPE_CHECKING, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction - from ..datacenters import BoundDatacenter from ..firewalls import BoundFirewall from ..floating_ips import BoundFloatingIP from ..images import BoundImage @@ -56,12 +54,6 @@ class Server(BaseDomain, DomainIdentityMixin): :param public_net: :class:`PublicNetwork ` Public network information. :param server_type: :class:`BoundServerType ` - :param datacenter: :class:`BoundDatacenter ` - - This property is deprecated and will be removed after 1 July 2026. - Please use the ``location`` property instead. - - See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters. :param location: :class:`BoundLocation ` :param image: :class:`BoundImage `, None :param iso: :class:`BoundIso `, None @@ -108,7 +100,7 @@ class Server(BaseDomain, DomainIdentityMixin): STATUS_UNKNOWN = "unknown" """Server Status unknown""" - __properties__ = ( + __api_properties__ = ( "id", "name", "status", @@ -131,14 +123,7 @@ class Server(BaseDomain, DomainIdentityMixin): "primary_disk_size", "placement_group", ) - __api_properties__ = ( - *__properties__, - "datacenter", - ) - __slots__ = ( - *__properties__, - "_datacenter", - ) + __slots__ = __api_properties__ # pylint: disable=too-many-locals def __init__( @@ -149,7 +134,6 @@ def __init__( created: str | None = None, public_net: PublicNetwork | None = None, server_type: BoundServerType | None = None, - datacenter: BoundDatacenter | None = None, location: BoundLocation | None = None, image: BoundImage | None = None, iso: BoundIso | None = None, @@ -172,7 +156,6 @@ def __init__( self.created = self._parse_datetime(created) self.public_net = public_net self.server_type = server_type - self.datacenter = datacenter self.location = location self.image = image self.iso = iso @@ -199,24 +182,6 @@ def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: return o return None - @property - def datacenter(self) -> BoundDatacenter | None: - """ - :meta private: - """ - warnings.warn( - "The 'datacenter' property is deprecated and will be removed after 1 July 2026. " - "Please use the 'location' property instead. " - "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters.", - DeprecationWarning, - stacklevel=2, - ) - return self._datacenter - - @datacenter.setter - def datacenter(self, value: BoundDatacenter | None) -> None: - self._datacenter = value - class ServerProtection(TypedDict): rebuild: bool diff --git a/tests/unit/primary_ips/conftest.py b/tests/unit/primary_ips/conftest.py index 361de307..3d84d871 100644 --- a/tests/unit/primary_ips/conftest.py +++ b/tests/unit/primary_ips/conftest.py @@ -14,10 +14,6 @@ def primary_ip1(): "assignee_type": "server", "auto_delete": True, "blocked": False, - "datacenter": { - "id": 4, - "name": "fsn1-dc14", - }, "location": { "id": 1, "name": "fsn1", @@ -42,10 +38,6 @@ def primary_ip2(): "assignee_type": "server", "auto_delete": True, "blocked": False, - "datacenter": { - "id": 4, - "name": "fsn1-dc14", - }, "location": { "id": 1, "name": "fsn1", diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 638acb2b..f8df0225 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -5,7 +5,6 @@ import pytest from hcloud import Client -from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.locations import BoundLocation, Location from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient @@ -62,13 +61,6 @@ def test_init(self, primary_ip1): assert o.location.id == 1 assert o.location.name == "fsn1" - with pytest.deprecated_call(): - datacenter = o.datacenter - - assert isinstance(datacenter, BoundDatacenter) - assert datacenter.id == 4 - assert datacenter.name == "fsn1-dc14" - class TestPrimaryIPsClient: @pytest.fixture() @@ -169,37 +161,6 @@ def test_create_with_location( assert_bound_primary_ip1(result.primary_ip, resource_client) assert result.action is None - def test_create_with_datacenter( - self, - request_mock: mock.MagicMock, - resource_client: PrimaryIPsClient, - primary_ip1, - ): - request_mock.return_value = { - "primary_ip": primary_ip1, - "action": None, - } - - with pytest.deprecated_call(): - result = resource_client.create( - type="ipv4", - name="primary-ip1", - datacenter=Datacenter(name="fsn1-dc14"), - ) - - request_mock.assert_called_with( - method="POST", - url="/primary_ips", - json={ - "name": "primary-ip1", - "type": "ipv4", - "datacenter": "fsn1-dc14", - "auto_delete": False, - }, - ) - assert_bound_primary_ip1(result.primary_ip, resource_client) - assert result.action is None - def test_create_with_assignee_id( self, request_mock: mock.MagicMock, diff --git a/tests/unit/servers/conftest.py b/tests/unit/servers/conftest.py index 01649320..2ccf5d64 100644 --- a/tests/unit/servers/conftest.py +++ b/tests/unit/servers/conftest.py @@ -58,25 +58,6 @@ def response_simple_server(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", @@ -159,25 +140,6 @@ def response_create_simple_server(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", @@ -281,25 +243,6 @@ def response_update_server(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", @@ -444,25 +387,6 @@ def response_simple_servers(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", @@ -546,25 +470,6 @@ def response_simple_servers(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", @@ -643,25 +548,6 @@ def response_full_server(): "storage_type": "local", "cpu_type": "shared", }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071, - }, - "server_types": { - "supported": [1, 2, 3], - "available": [1, 2, 3], - "available_for_migration": [1, 2, 3], - }, - }, "image": { "id": 4711, "type": "snapshot", diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 0a09544d..3d3ab085 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -6,7 +6,6 @@ from hcloud import Client from hcloud.actions import BoundAction -from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.firewalls import BoundFirewall, Firewall from hcloud.floating_ips import BoundFloatingIP from hcloud.images import BoundImage, Image @@ -100,14 +99,6 @@ def test_init(self, response_full_server): assert bound_server.public_net.floating_ips[0].id == 478 assert bound_server.public_net.floating_ips[0].complete is False - with pytest.deprecated_call(): - datacenter = bound_server.datacenter - - assert isinstance(datacenter, BoundDatacenter) - assert datacenter._client == bound_server._client._parent.datacenters - assert datacenter.id == 1 - assert datacenter.complete is True - assert isinstance(bound_server.server_type, BoundServerType) assert ( bound_server.server_type._client @@ -281,46 +272,6 @@ def test_get_by_name( assert bound_server.id == 1 assert bound_server.name == "my-server" - def test_create_with_datacenter( - self, - request_mock: mock.MagicMock, - servers_client: ServersClient, - response_create_simple_server, - ): - request_mock.return_value = response_create_simple_server - - with pytest.deprecated_call(): - response = servers_client.create( - "my-server", - server_type=ServerType(name="cx11"), - image=Image(id=4711), - datacenter=Datacenter(id=1), - ) - - request_mock.assert_called_with( - method="POST", - url="/servers", - json={ - "name": "my-server", - "server_type": "cx11", - "image": 4711, - "datacenter": 1, - "start_after_create": True, - }, - ) - - bound_server = response.server - bound_action = response.action - - assert bound_server._client is servers_client - assert bound_server.id == 1 - assert bound_server.name == "my-server" - - assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._parent.actions - assert bound_action.id == 1 - assert bound_action.command == "create_server" - def test_create_with_location( self, request_mock: mock.MagicMock, From d6148f4826255e2c9dfe3f2cfac31cc75a9e7eab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:52:53 +0200 Subject: [PATCH 38/72] chore(deps): update dependency mypy to >=2.3,<2.4 (#673) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ff037d56..d6c47860 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", - "mypy>=2.2,<2.3", + "mypy>=2.3,<2.4", "types-python-dateutil", "types-requests", ], From 629a8f8c4c00b2ca5b72db2e17cebc319cf10ffc Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 20 Jul 2026 18:29:19 +0200 Subject: [PATCH 39/72] test: update load balancer types tests (#675) --- tests/unit/load_balancer_types/conftest.py | 137 +++++------------- tests/unit/load_balancer_types/test_client.py | 103 +++++++------ 2 files changed, 95 insertions(+), 145 deletions(-) diff --git a/tests/unit/load_balancer_types/conftest.py b/tests/unit/load_balancer_types/conftest.py index 0736d787..f0a2ec5b 100644 --- a/tests/unit/load_balancer_types/conftest.py +++ b/tests/unit/load_balancer_types/conftest.py @@ -4,114 +4,49 @@ @pytest.fixture() -def load_balancer_type_response(): +def load_balancer_type1(): return { - "load_balancer_type": { - "id": 1, - "name": "LB11", - "description": "LB11", - "max_connections": 1, - "max_services": 1, - "max_targets": 1, - "max_assigned_certificates": 1, - "deprecated": None, - "prices": [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - } - ], - } - } - - -@pytest.fixture() -def two_load_balancer_types_response(): - return { - "load_balancer_types": [ - { - "id": 1, - "name": "LB11", - "description": "LB11D", - "max_connections": 1, - "max_services": 1, - "max_targets": 1, - "max_assigned_certificates": 1, - "deprecated": None, - "prices": [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - } - ], - }, + "id": 1, + "name": "lb11", + "description": "LB11", + "max_connections": 10000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + "prices": [ { - "id": 2, - "name": "LB21", - "description": "LB21D", - "max_connections": 2, - "max_services": 2, - "max_targets": 2, - "max_assigned_certificates": 2, - "deprecated": None, - "prices": [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - } - ], + "location": "fsn1", + "price_hourly": {"net": "0.0120", "gross": "0.0120"}, + "price_monthly": {"net": "7.4900", "gross": "7.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, }, - ] + ], } @pytest.fixture() -def one_load_balancer_types_response(): +def load_balancer_type2(): return { - "load_balancer_types": [ + "id": 2, + "name": "lb21", + "description": "LB21", + "max_connections": 20000, + "max_services": 15, + "max_targets": 75, + "max_assigned_certificates": 25, + "deprecation": None, + "prices": [ { - "id": 2, - "name": "LB21", - "description": "LB21D", - "max_connections": 2, - "max_services": 2, - "max_targets": 2, - "max_assigned_certificates": 2, - "deprecated": None, - "prices": [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000", - }, - } - ], - } - ] + "location": "fsn1", + "price_hourly": {"net": "0.0344", "gross": "0.0344"}, + "price_monthly": {"net": "21.4900", "gross": "21.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, + }, + ], } diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index c9f356dc..8a5bc628 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -17,31 +17,51 @@ def test_get_by_id( self, request_mock: mock.MagicMock, load_balancer_types_client: LoadBalancerTypesClient, - load_balancer_type_response, + load_balancer_type1, ): - request_mock.return_value = load_balancer_type_response + request_mock.return_value = { + "load_balancer_type": load_balancer_type1, + } - load_balancer_type = load_balancer_types_client.get_by_id(1) + result = load_balancer_types_client.get_by_id(1) request_mock.assert_called_with( method="GET", url="/load_balancer_types/1", ) - assert load_balancer_type._client is load_balancer_types_client - assert load_balancer_type.id == 1 - assert load_balancer_type.name == "LB11" + + assert result._client is load_balancer_types_client + assert result.id == 1 + assert result.name == "lb11" + assert result.description == "LB11" + assert result.max_connections == 10000 + assert result.max_services == 5 + assert result.max_targets == 25 + assert result.max_assigned_certificates == 10 + assert result.prices == [ + { + "location": "fsn1", + "price_hourly": {"net": "0.0120", "gross": "0.0120"}, + "price_monthly": {"net": "7.4900", "gross": "7.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, + } + ] @pytest.mark.parametrize( - "params", [{"name": "LB11", "page": 1, "per_page": 10}, {"name": ""}, {}] + "params", [{"name": "lb11", "page": 1, "per_page": 10}, {"name": ""}, {}] ) def test_get_list( self, request_mock: mock.MagicMock, load_balancer_types_client: LoadBalancerTypesClient, - two_load_balancer_types_response, + load_balancer_type1, + load_balancer_type2, params, ): - request_mock.return_value = two_load_balancer_types_response + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2], + } result = load_balancer_types_client.get_list(**params) @@ -51,33 +71,31 @@ def test_get_list( params=params, ) - load_balancer_types = result.load_balancer_types assert result.meta is not None + assert len(result.load_balancer_types) == 2 - assert len(load_balancer_types) == 2 - - load_balancer_types1 = load_balancer_types[0] - load_balancer_types2 = load_balancer_types[1] - - assert load_balancer_types1._client is load_balancer_types_client - assert load_balancer_types1.id == 1 - assert load_balancer_types1.name == "LB11" + assert result.load_balancer_types[0]._client is load_balancer_types_client + assert result.load_balancer_types[0].id == 1 + assert result.load_balancer_types[0].name == "lb11" - assert load_balancer_types2._client is load_balancer_types_client - assert load_balancer_types2.id == 2 - assert load_balancer_types2.name == "LB21" + assert result.load_balancer_types[1]._client is load_balancer_types_client + assert result.load_balancer_types[1].id == 2 + assert result.load_balancer_types[1].name == "lb21" - @pytest.mark.parametrize("params", [{"name": "LB21"}]) + @pytest.mark.parametrize("params", [{"name": "lb11"}]) def test_get_all( self, request_mock: mock.MagicMock, load_balancer_types_client: LoadBalancerTypesClient, - two_load_balancer_types_response, + load_balancer_type1, + load_balancer_type2, params, ): - request_mock.return_value = two_load_balancer_types_response + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2] + } - load_balancer_types = load_balancer_types_client.get_all(**params) + result = load_balancer_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) @@ -87,37 +105,34 @@ def test_get_all( params=params, ) - assert len(load_balancer_types) == 2 + assert len(result) == 2 - load_balancer_types1 = load_balancer_types[0] - load_balancer_types2 = load_balancer_types[1] + assert result[0]._client is load_balancer_types_client + assert result[0].id == 1 + assert result[0].name == "lb11" - assert load_balancer_types1._client is load_balancer_types_client - assert load_balancer_types1.id == 1 - assert load_balancer_types1.name == "LB11" - - assert load_balancer_types2._client is load_balancer_types_client - assert load_balancer_types2.id == 2 - assert load_balancer_types2.name == "LB21" + assert result[1]._client is load_balancer_types_client + assert result[1].id == 2 + assert result[1].name == "lb21" def test_get_by_name( self, request_mock: mock.MagicMock, load_balancer_types_client: LoadBalancerTypesClient, - one_load_balancer_types_response, + load_balancer_type1, ): - request_mock.return_value = one_load_balancer_types_response - - load_balancer_type = load_balancer_types_client.get_by_name("LB21") + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1], + } - params = {"name": "LB21"} + result = load_balancer_types_client.get_by_name("lb11") request_mock.assert_called_with( method="GET", url="/load_balancer_types", - params=params, + params={"name": "lb11"}, ) - assert load_balancer_type._client is load_balancer_types_client - assert load_balancer_type.id == 2 - assert load_balancer_type.name == "LB21" + assert result._client is load_balancer_types_client + assert result.id == 1 + assert result.name == "lb11" From aeebcf8bd32df390163089a33c57815bb7214763 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 21 Jul 2026 09:57:30 +0200 Subject: [PATCH 40/72] feat: add deprecation info to load balancer type (#674) Add the `deprecation` object property to the `LoadBalancerType` domain. https://docs.hetzner.cloud/changelog#2026-06-05-deprecation-info-for-load-balancer-types --- hcloud/load_balancer_types/domain.py | 6 +++++- tests/unit/load_balancer_types/test_client.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/hcloud/load_balancer_types/domain.py b/hcloud/load_balancer_types/domain.py index 1e594e94..7814a4f4 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -3,6 +3,7 @@ from typing import Any from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo __all__ = [ "LoadBalancerType", @@ -28,7 +29,7 @@ class LoadBalancerType(BaseDomain, DomainIdentityMixin): Max amount of certificates the Load Balancer can serve :param prices: List of dict Prices in different locations - + :param deprecation: Define if and when the Load Balancer Type is deprecated. """ __api_properties__ = ( @@ -40,6 +41,7 @@ class LoadBalancerType(BaseDomain, DomainIdentityMixin): "max_targets", "max_assigned_certificates", "prices", + "deprecation", ) __slots__ = __api_properties__ @@ -53,6 +55,7 @@ def __init__( max_targets: int | None = None, max_assigned_certificates: int | None = None, prices: list[dict[str, Any]] | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -62,3 +65,4 @@ def __init__( self.max_targets = max_targets self.max_assigned_certificates = max_assigned_certificates self.prices = prices + self.deprecation = deprecation and DeprecationInfo.from_dict(deprecation) diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index 8a5bc628..3c774a30 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from dateutil.parser import isoparse from hcloud import Client from hcloud.load_balancer_types import LoadBalancerTypesClient @@ -47,6 +48,10 @@ def test_get_by_id( "included_traffic": 21990232555520, } ] + 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": "lb11", "page": 1, "per_page": 10}, {"name": ""}, {}] From 381d71a34994bc3123a16bde4321cf566f5f3793 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:28:31 +0200 Subject: [PATCH 41/72] chore(deps): replace pre-commit hook pre-commit/mirrors-prettier with jooola/pre-commit-prettier 3.9.6 (#676) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 27bc959c..c35de9e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,8 +22,8 @@ repos: args: [--fix=lf] - id: trailing-whitespace - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.1.0 + - repo: https://github.com/jooola/pre-commit-prettier + rev: 3.9.6 hooks: - id: prettier files: \.(md|ya?ml|js|css)$ From 48f3424b49ece33652cd8ca7163e857ca3fecbb9 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:30:42 +0200 Subject: [PATCH 42/72] chore(main): release v2.23.0 (#672) ### Removed deprecated Datacenter property from Server and PrimaryIP Removed the deprecated Datacenter property from the Server and PrimaryIP resources. Since the property was already removed from the Hetzner Cloud API, we do not consider this a breaking change (see [changelog entry](https://docs.hetzner.cloud/changelog#2026-07-01-removing-datacenters)). > [!IMPORTANT] > **Action required:** Please update all code that accesses `server.datacenter` or `primary_ip.datacenter` to use the `location` property instead, as shown below. **Before:** ```python server = client.server.get_by_id(5) print(server.datacenter) primary_ip = client.primary_ip.get_by_id(5) print(primary_ip.datacenter) ``` **After:** ```python server = client.server.get_by_id(5) print(server.location) primary_ip = client.primary_ip.get_by_id(5) print(primary_ip.location) ``` ### Features - remove datacenter property from server and primary_ip (#668) ([abdadf3](https://github.com/hetznercloud/hcloud-python/commit/abdadf3e7cc5d0edeb38f369955fa83f71b102e5)) - add deprecation info to load balancer type (#674) ([aeebcf8](https://github.com/hetznercloud/hcloud-python/commit/aeebcf8bd32df390163089a33c57815bb7214763)) --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d40b4b6..2d744fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [v2.23.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.23.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.22.0...v2.23.0) + +### Removed deprecated Datacenter property from Server and PrimaryIP + +Removed the deprecated Datacenter property from the Server and PrimaryIP resources. Since the property was already removed from the Hetzner Cloud API, we do not consider this a breaking change (see [changelog entry](https://docs.hetzner.cloud/changelog#2026-07-01-removing-datacenters)). + +> [!IMPORTANT] +> **Action required:** Please update all code that accesses `server.datacenter` or `primary_ip.datacenter` to use the `location` property instead, as shown below. + +**Before:** + +```python +server = client.server.get_by_id(5) +print(server.datacenter) + +primary_ip = client.primary_ip.get_by_id(5) +print(primary_ip.datacenter) +``` + +**After:** + +```python +server = client.server.get_by_id(5) +print(server.location) + +primary_ip = client.primary_ip.get_by_id(5) +print(primary_ip.location) +``` + +### Features + +- remove datacenter property from server and primary_ip (#668) ([abdadf3](https://github.com/hetznercloud/hcloud-python/commit/abdadf3e7cc5d0edeb38f369955fa83f71b102e5)) +- add deprecation info to load balancer type (#674) ([aeebcf8](https://github.com/hetznercloud/hcloud-python/commit/aeebcf8bd32df390163089a33c57815bb7214763)) + ## [v2.22.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.22.0) ### Datacenters resource is now deprecated diff --git a/hcloud/_version.py b/hcloud/_version.py index 7576cdbd..8408a231 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.22.0" # x-releaser-pleaser-version +__version__ = "2.23.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index d6c47860..93f2a6ab 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.22.0", # x-releaser-pleaser-version + version="2.23.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 65e74ba2b50274f6f17cedf4570ab34e84c48e58 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:17:08 +0200 Subject: [PATCH 43/72] chore(deps): update actions/setup-python action to v7 (#678) --- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f1910d8d..5bb91395 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: 3.x @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: 3.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08d3de84..6fcbe9dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: 3.x diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90181486..06904c74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} From 5e5e03116eb85c8d8af5ac59d19b3720c9f7b8db Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:19:08 +0200 Subject: [PATCH 44/72] chore(deps): update github actions (#677) --- .github/workflows/links.yml | 2 +- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 2791863e..8ca72686 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5bb91395..3c62238d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,7 +9,7 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 @@ -25,7 +25,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6fcbe9dc..13ce6a26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 @@ -53,4 +53,4 @@ jobs: path: dist/ - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 06904c74..a280769f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,7 @@ jobs: name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 From 522122a9953e4ee8cf3c737a04137c93a8005db4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:34:51 +0200 Subject: [PATCH 45/72] chore(deps): update pre-commit hook pycqa/isort to v9 (#679) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c35de9e0..8d052a2a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--py310-plus] - repo: https://github.com/pycqa/isort - rev: 8.0.1 + rev: 9.0.1 hooks: - id: isort From 85b401cfe08ba9ef349e70b3aedd92f3c994965c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:36:46 +0200 Subject: [PATCH 46/72] chore(deps): update dependency coverage to >=7.16,<7.17 (#680) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 93f2a6ab..813d654e 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.15,<7.16", + "coverage>=7.16,<7.17", "pylint>=4,<4.1", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", From e9b8c76d151da8e9cd1335642e9ec1bbff8a4cf4 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 8 Sep 2026 16:21:55 +0200 Subject: [PATCH 47/72] refactor: modernize images client tests (#681) --- tests/unit/images/conftest.py | 178 ++++++----------------- tests/unit/images/test_client.py | 240 ++++++++++++++++--------------- 2 files changed, 170 insertions(+), 248 deletions(-) diff --git a/tests/unit/images/conftest.py b/tests/unit/images/conftest.py index 0917fbae..887c1eb9 100644 --- a/tests/unit/images/conftest.py +++ b/tests/unit/images/conftest.py @@ -4,141 +4,57 @@ @pytest.fixture() -def image_response(): +def image1(): return { - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": 1, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } + "id": 45557056, + "name": "debian-11", + "architecture": "x86", + "bound_to": None, + "created": "2021-08-16T11:12:01Z", + "created_from": None, + "deleted": None, + "deprecated": "2026-08-31T06:21:44Z", + "description": "Debian 11", + "disk_size": 5, + "image_size": None, + "labels": {}, + "os_flavor": "debian", + "os_version": "11", + "protection": { + "delete": False, + }, + "rapid_deploy": True, + "status": "available", + "type": "system", } @pytest.fixture() -def two_images_response(): +def image2(): return { - "images": [ - { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - }, - { - "id": 4712, - "type": "system", - "status": "available", - "name": "ubuntu-18.10", - "description": "Ubuntu 18.10 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - }, - ] - } - - -@pytest.fixture() -def one_images_response(): - return { - "images": [ - { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } - ] - } - - -@pytest.fixture() -def response_update_image(): - return { - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": None, - "description": "My new Image description", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "arm", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } - } - - -@pytest.fixture() -def response_get_actions(): - return { - "actions": [ - { - "id": 13, - "command": "change_protection", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "image"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - ] + "id": 429564329, + "name": None, + "architecture": "x86", + "bound_to": 159969127, + "created": "2026-09-08T13:44:08Z", + "created_from": { + "id": 159969127, + "name": "server1", + }, + "deleted": None, + "deprecated": None, + "description": "snapshot 2026-09-08T13:44:08Z", + "disk_size": 80, + "image_size": 0.7237785009765625, + "labels": { + "key": "value", + }, + "os_flavor": "debian", + "os_version": "13", + "protection": { + "delete": True, + }, + "rapid_deploy": False, + "status": "available", + "type": "snapshot", } diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index d88b36bf..38251a11 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -1,16 +1,33 @@ from __future__ import annotations -import datetime -from datetime import timezone from unittest import mock import pytest +from dateutil.parser import isoparse from hcloud import Client from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_image1( + o: BoundImage, + client: ImagesClient, +): + assert isinstance(o, BoundImage) + assert o._client is client + assert o.id == 45557056 + + +def assert_bound_image2( + o: BoundImage, + client: ImagesClient, +): + assert isinstance(o, BoundImage) + assert o._client is client + assert o.id == 429564329 class TestBoundImage(BoundModelTestCase): @@ -25,62 +42,75 @@ def resource_client(self, client: Client): return client.images @pytest.fixture() - def bound_model(self, resource_client): - return BoundImage(resource_client, data=dict(id=14)) - - def test_init(self, image_response): - bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) - - assert bound_image.id == 4711 - assert bound_image.type == "snapshot" - assert bound_image.status == "available" - assert bound_image.name == "ubuntu-20.04" - assert bound_image.description == "Ubuntu 20.04 Standard 64 bit" - assert bound_image.image_size == 2.3 - assert bound_image.disk_size == 10 - assert bound_image.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=timezone.utc - ) - assert bound_image.os_flavor == "ubuntu" - assert bound_image.os_version == "16.04" - assert bound_image.architecture == "x86" - assert bound_image.rapid_deploy is False - assert bound_image.deprecated == datetime.datetime( - 2018, 2, 28, 0, 0, tzinfo=timezone.utc - ) - - assert isinstance(bound_image.created_from, BoundServer) - assert bound_image.created_from.id == 1 - assert bound_image.created_from.name == "Server" - assert bound_image.created_from.complete is False - - assert isinstance(bound_image.bound_to, BoundServer) - assert bound_image.bound_to.id == 1 - assert bound_image.bound_to.complete is False + def bound_model(self, resource_client, image1): + return BoundImage(resource_client, data=image1) + + def test_init(self, image1, image2): + o = BoundImage(client=mock.MagicMock(), data=image1) + + assert o.id == 45557056 + assert o.type == "system" + assert o.name == "debian-11" + assert o.architecture == "x86" + assert o.status == "available" + assert o.description == "Debian 11" + assert o.image_size is None + assert o.disk_size == 5 + assert o.created == isoparse("2021-08-16T11:12:01Z") + assert o.created_from is None + assert o.bound_to is None + assert o.os_flavor == "debian" + assert o.os_version == "11" + assert o.rapid_deploy is True + assert o.labels == {} + assert o.protection == {"delete": False} + assert o.deprecated == isoparse("2026-08-31T06:21:44Z") + + o = BoundImage(client=mock.MagicMock(), data=image2) + + assert o.id == 429564329 + assert o.type == "snapshot" + assert o.name is None + assert o.architecture == "x86" + assert o.status == "available" + assert o.description == "snapshot 2026-09-08T13:44:08Z" + assert o.image_size == 0.7237785009765625 + assert o.disk_size == 80 + assert o.created == isoparse("2026-09-08T13:44:08Z") + assert isinstance(o.created_from, BoundServer) + assert o.created_from.id == 159969127 + assert o.created_from.name == "server1" + assert isinstance(o.bound_to, BoundServer) + assert o.bound_to.id == 159969127 + assert o.os_flavor == "debian" + assert o.os_version == "13" + assert o.rapid_deploy is False + assert o.labels == {"key": "value"} + assert o.protection == {"delete": True} + assert o.deprecated is None class TestImagesClient: @pytest.fixture() - def images_client(self, client: Client): - return ImagesClient(client) + def resource_client(self, client: Client): + return client.images def test_get_by_id( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - image_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = image_response + request_mock.return_value = {"image": image1} - image = images_client.get_by_id(1) + result = resource_client.get_by_id(1) request_mock.assert_called_with( method="GET", url="/images/1", ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" + + assert_bound_image1(result, resource_client) @pytest.mark.parametrize( "params", @@ -102,13 +132,14 @@ def test_get_by_id( def test_get_list( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - two_images_response, + resource_client: ImagesClient, + image1, + image2, params, ): - request_mock.return_value = two_images_response + request_mock.return_value = {"images": [image1, image2]} - result = images_client.get_list(**params) + result = resource_client.get_list(**params) request_mock.assert_called_with( method="GET", @@ -116,21 +147,11 @@ def test_get_list( params=params, ) - images = result.images assert result.meta is not None + assert len(result.images) == 2 - assert len(images) == 2 - - images1 = images[0] - images2 = images[1] - - assert images1._client is images_client - assert images1.id == 4711 - assert images1.name == "ubuntu-20.04" - - assert images2._client is images_client - assert images2.id == 4712 - assert images2.name == "ubuntu-18.10" + assert_bound_image1(result.images[0], resource_client) + assert_bound_image2(result.images[1], resource_client) @pytest.mark.parametrize( "params", @@ -149,13 +170,14 @@ def test_get_list( def test_get_all( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - two_images_response, + resource_client: ImagesClient, + image1, + image2, params, ): - request_mock.return_value = two_images_response + request_mock.return_value = {"images": [image1, image2]} - images = images_client.get_all(**params) + result = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) @@ -165,64 +187,46 @@ def test_get_all( params=params, ) - assert len(images) == 2 - - images1 = images[0] - images2 = images[1] - - assert images1._client is images_client - assert images1.id == 4711 - assert images1.name == "ubuntu-20.04" - - assert images2._client is images_client - assert images2.id == 4712 - assert images2.name == "ubuntu-18.10" + assert len(result) == 2 + assert_bound_image1(result[0], resource_client) + assert_bound_image2(result[1], resource_client) def test_get_by_name( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - one_images_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = one_images_response + request_mock.return_value = {"images": [image1]} with pytest.deprecated_call(): - image = images_client.get_by_name("ubuntu-20.04") - - params = {"name": "ubuntu-20.04"} + result = resource_client.get_by_name("debian-11") request_mock.assert_called_with( method="GET", url="/images", - params=params, + params={"name": "debian-11"}, ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" + assert_bound_image1(result, resource_client) def test_get_by_name_and_architecture( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - one_images_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = one_images_response - - image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") + request_mock.return_value = {"images": [image1]} - params = {"name": "ubuntu-20.04", "architecture": ["x86"]} + result = resource_client.get_by_name_and_architecture("debian-11", "x86") request_mock.assert_called_with( method="GET", url="/images", - params=params, + params={"name": "debian-11", "architecture": ["x86"]}, ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" - assert image.architecture == "x86" + assert_bound_image1(result, resource_client) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -230,14 +234,17 @@ def test_get_by_name_and_architecture( def test_update( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - response_update_image, + image1, ): - request_mock.return_value = response_update_image + request_mock.return_value = {"image": image1} - image = images_client.update( - image, description="My new Image description", type="snapshot", labels={} + image = resource_client.update( + image, + description="My new Image description", + type="snapshot", + labels={}, ) request_mock.assert_called_with( @@ -250,8 +257,7 @@ def test_update( }, ) - assert image.id == 4711 - assert image.description == "My new Image description" + assert_bound_image1(image, resource_client) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -259,22 +265,23 @@ def test_update( def test_change_protection( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - action_response, + action1_running, ): - request_mock.return_value = action_response + request_mock.return_value = {"action": action1_running} - action = images_client.change_protection(image, True) + action = resource_client.change_protection(image, True) request_mock.assert_called_with( method="POST", url="/images/1/actions/change_protection", - json={"delete": True}, + json={ + "delete": True, + }, ) - assert action.id == 1 - assert action.progress == 0 + assert_bound_action1(action, resource_client._parent.actions) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -282,17 +289,16 @@ def test_change_protection( def test_delete( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - action_response, ): - request_mock.return_value = action_response + request_mock.return_value = {} - delete_success = images_client.delete(image) + result = resource_client.delete(image) request_mock.assert_called_with( method="DELETE", url="/images/1", ) - assert delete_success is True + assert result is True From 21b5cdb92f11ffe12a60492fb3f5c0a684f85f0b Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 8 Sep 2026 16:34:15 +0200 Subject: [PATCH 48/72] refactor: add typing to BaseDomain.from_dict class method (#682) --- hcloud/core/domain.py | 6 ++++-- hcloud/isos/domain.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index 6659df80..cf7ddd19 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, overload +from typing import Any, TypeVar, overload from dateutil.parser import isoparse @@ -12,12 +12,14 @@ "Meta", ] +T = TypeVar("T", bound="BaseDomain") + class BaseDomain: __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data: dict[str, Any]): # type: ignore[no-untyped-def] + def from_dict(cls: type[T], data: dict[str, Any]) -> T: """ Build the domain object from the data dict. """ diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py index 5fd50c6e..9ce845af 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -72,4 +72,4 @@ def deprecated(self) -> datetime | None: ) if self.deprecation is None: return None - return self.deprecation.unavailable_after # type: ignore[no-any-return] + return self.deprecation.unavailable_after From 3bddb0d1d5726b8628152fcf1e5ef4104150b8b7 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 11 Sep 2026 13:08:27 +0200 Subject: [PATCH 49/72] feat: add deprecation info to image (#683) Related to https://docs.hetzner.cloud/changelog#2026-09-08-removing-the-deprecated-field --- hcloud/images/domain.py | 38 +++++++++++++++++++++++++++----- tests/unit/images/conftest.py | 7 ++++-- tests/unit/images/test_client.py | 9 ++++++-- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index 915ace64..6e245bdc 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,8 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +import warnings +from datetime import datetime +from typing import TYPE_CHECKING, Any, TypedDict from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo if TYPE_CHECKING: from ..actions import BoundAction @@ -52,8 +55,10 @@ class Image(BaseDomain, DomainIdentityMixin): Protection configuration for the image :param deprecated: datetime, None Point in time when the image is considered to be deprecated (in ISO-8601 format) + :param deprecation: + Describes whether the resource is deprecated. :param labels: Dict - User-defined labels (key-value pairs) + User-defined labels (key-value pairs) """ __api_properties__ = ( @@ -73,11 +78,11 @@ class Image(BaseDomain, DomainIdentityMixin): "protection", "labels", "created", - "deprecated", + "deprecation", ) __slots__ = __api_properties__ - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,unused-argument def __init__( self, id: int | None = None, @@ -87,7 +92,7 @@ def __init__( description: str | None = None, image_size: int | None = None, disk_size: int | None = None, - deprecated: str | None = None, + deprecated: str | None = None, # Preserved for backward compatibility bound_to: Server | BoundServer | None = None, os_flavor: str | None = None, os_version: str | None = None, @@ -97,6 +102,7 @@ def __init__( protection: ImageProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -105,7 +111,6 @@ def __init__( self.description = description self.image_size = image_size self.disk_size = disk_size - self.deprecated = self._parse_datetime(deprecated) self.bound_to = bound_to self.os_flavor = os_flavor self.os_version = os_version @@ -115,6 +120,27 @@ def __init__( self.protection = protection self.labels = labels self.status = status + self.deprecation = ( + None if deprecation is None else DeprecationInfo.from_dict(deprecation) + ) + + @property + def deprecated(self) -> datetime | None: + """ + .. deprecated:: 2.24.0 + The 'deprecated' property is deprecated. Please use to the '.deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2026-09-08-removing-the-deprecated-field. + """ + warnings.warn( + "The 'deprecated' property is deprecated. Please use to the '.deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2026-09-08-removing-the-deprecated-field.", + DeprecationWarning, + stacklevel=2, + ) + if self.deprecation is None: + return None + return self.deprecation.announced class ImageProtection(TypedDict): diff --git a/tests/unit/images/conftest.py b/tests/unit/images/conftest.py index 887c1eb9..5c83fa34 100644 --- a/tests/unit/images/conftest.py +++ b/tests/unit/images/conftest.py @@ -13,7 +13,10 @@ def image1(): "created": "2021-08-16T11:12:01Z", "created_from": None, "deleted": None, - "deprecated": "2026-08-31T06:21:44Z", + "deprecation": { + "announced": "2026-08-31T06:21:44Z", + "unavailable_after": "2026-12-01T00:00:00Z", + }, "description": "Debian 11", "disk_size": 5, "image_size": None, @@ -42,7 +45,7 @@ def image2(): "name": "server1", }, "deleted": None, - "deprecated": None, + "deprecation": None, "description": "snapshot 2026-09-08T13:44:08Z", "disk_size": 80, "image_size": 0.7237785009765625, diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 38251a11..0ae6b07f 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -64,7 +64,10 @@ def test_init(self, image1, image2): assert o.rapid_deploy is True assert o.labels == {} assert o.protection == {"delete": False} - assert o.deprecated == isoparse("2026-08-31T06:21:44Z") + with pytest.deprecated_call(): + assert o.deprecated == isoparse("2026-08-31T06:21:44Z") + assert o.deprecation.announced == isoparse("2026-08-31T06:21:44Z") + assert o.deprecation.unavailable_after == isoparse("2026-12-01T00:00:00Z") o = BoundImage(client=mock.MagicMock(), data=image2) @@ -87,7 +90,9 @@ def test_init(self, image1, image2): assert o.rapid_deploy is False assert o.labels == {"key": "value"} assert o.protection == {"delete": True} - assert o.deprecated is None + with pytest.deprecated_call(): + assert o.deprecated is None + assert o.deprecation is None class TestImagesClient: From ca4efb872ff4135cac3901be18d578b8118e6b58 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:13:30 +0200 Subject: [PATCH 50/72] chore(main): release v2.24.0 (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - add deprecation info to image (#683) ([3bddb0d](https://github.com/hetznercloud/hcloud-python/commit/3bddb0d1d5726b8628152fcf1e5ef4104150b8b7)) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 8 ++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d744fe0..c5ce09f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v2.24.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.24.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.23.0...v2.24.0) + +### Features + +- add deprecation info to image (#683) ([3bddb0d](https://github.com/hetznercloud/hcloud-python/commit/3bddb0d1d5726b8628152fcf1e5ef4104150b8b7)) + ## [v2.23.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.23.0) [Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.22.0...v2.23.0) diff --git a/hcloud/_version.py b/hcloud/_version.py index 8408a231..0a12a055 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.23.0" # x-releaser-pleaser-version +__version__ = "2.24.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 813d654e..00d2440a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.23.0", # x-releaser-pleaser-version + version="2.24.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 9d5d21db356c00fd09ef524d844a93c16552dc89 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 16 Sep 2026 16:35:57 +0200 Subject: [PATCH 51/72] feat: list network members (#687) 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))] ) From 1d7dea6917fd9109ac0821335e356d7b79cfb95c Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 16 Sep 2026 16:44:22 +0200 Subject: [PATCH 52/72] chore: add missing bound methods for list network members (#689) Related to #687 --- hcloud/networks/client.py | 56 ++++++++++++++++++++++++++++-- tests/unit/networks/test_client.py | 2 ++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index f94595d6..e884471c 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -96,6 +96,60 @@ def delete(self) -> bool: """ return self._client.delete(self) + def get_member_list( + self, + *, + 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 the Network. + + :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._client.get_member_list( + self, + type=type, + status=status, + subnet=subnet, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_member_all( + self, + *, + 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 the Network. + + :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. + """ + return self._client.get_member_all( + self, + type=type, + status=status, + subnet=subnet, + sort=sort, + ) + def get_actions_list( self, status: list[ActionStatus] | None = None, @@ -464,8 +518,6 @@ def get_member_all( :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, diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 60c60e35..5c1184da 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -22,6 +22,8 @@ class TestBoundNetwork(BoundModelTestCase): methods = [ BoundNetwork.update, BoundNetwork.delete, + BoundNetwork.get_member_list, + BoundNetwork.get_member_all, BoundNetwork.add_subnet, BoundNetwork.delete_subnet, BoundNetwork.add_route, From c6bc24a0e733c84a91404677195fe237a859e495 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 16 Sep 2026 21:14:38 +0200 Subject: [PATCH 53/72] fix: only send load balancer service certificates when defined (#690) The `to_payload` method was always adding the load balancer services `certificates` to the payload, which is not an expected behavior if we want to support partial updates. --- hcloud/load_balancers/domain.py | 10 +++++----- tests/unit/load_balancers/test_client.py | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 7561af2a..6d3af62d 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -186,7 +186,7 @@ def __init__( self.health_check = health_check self.http = http - # pylint: disable=too-many-branches + # pylint: disable=too-many-branches,too-many-statements def to_payload(self) -> dict[str, Any]: """ Generates the request payload from this domain object. @@ -214,10 +214,10 @@ def to_payload(self) -> dict[str, Any]: http["sticky_sessions"] = self.http.sticky_sessions if self.http.timeout_idle is not None: http["timeout_idle"] = self.http.timeout_idle - - http["certificates"] = [ - certificate.id for certificate in self.http.certificates or [] - ] + if self.http.certificates is not None: + http["certificates"] = [ + certificate.id for certificate in self.http.certificates + ] payload["http"] = http diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index d9558f6d..8ba8b4db 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -395,6 +395,7 @@ def test_add_service( redirect_http=True, sticky_sessions=True, timeout_idle=60, + certificates=[], ), ) action = resource_client.add_service(load_balancer, service) From f7b8a3195eeca770d0f446e3b6b8829e922eb2a9 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:22:36 +0200 Subject: [PATCH 54/72] chore(main): release v2.25.0 (#688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - list network members (#687) ([9d5d21d](https://github.com/hetznercloud/hcloud-python/commit/9d5d21db356c00fd09ef524d844a93c16552dc89)) ### Bug Fixes - only send load balancer service certificates when defined (#690) ([c6bc24a](https://github.com/hetznercloud/hcloud-python/commit/c6bc24a0e733c84a91404677195fe237a859e495)) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 12 ++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ce09f3..562ac124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [v2.25.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.24.0...v2.25.0) + +### Features + +- list network members (#687) ([9d5d21d](https://github.com/hetznercloud/hcloud-python/commit/9d5d21db356c00fd09ef524d844a93c16552dc89)) + +### Bug Fixes + +- only send load balancer service certificates when defined (#690) ([c6bc24a](https://github.com/hetznercloud/hcloud-python/commit/c6bc24a0e733c84a91404677195fe237a859e495)) + ## [v2.24.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.24.0) [Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.23.0...v2.24.0) diff --git a/hcloud/_version.py b/hcloud/_version.py index 0a12a055..36e172a5 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.24.0" # x-releaser-pleaser-version +__version__ = "2.25.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 00d2440a..c9549fba 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.24.0", # x-releaser-pleaser-version + version="2.25.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From e3bf6f2bcdb407607d7659ca4fe54bb47f6104c5 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 17 Sep 2026 09:43:45 +0200 Subject: [PATCH 55/72] fix: only send load balancer service health_check properties when defined (#691) The code was sending all the health_check properties unconditionally, which prevented from falling back on API defaults or to partially update the health_check values. --- hcloud/load_balancers/domain.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 6d3af62d..1782f9e9 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -222,13 +222,7 @@ def to_payload(self) -> dict[str, Any]: payload["http"] = http if self.health_check is not None: - health_check: dict[str, Any] = { - "protocol": self.health_check.protocol, - "port": self.health_check.port, - "interval": self.health_check.interval, - "timeout": self.health_check.timeout, - "retries": self.health_check.retries, - } + health_check: dict[str, Any] = {} if self.health_check.protocol is not None: health_check["protocol"] = self.health_check.protocol if self.health_check.port is not None: From 61d7bcc7c92b87bde6105bda97b0f8bd1f1721b7 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:46:25 +0200 Subject: [PATCH 56/72] chore(main): release v2.25.1 (#692) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - only send load balancer service health_check properties when defined (#691) ([e3bf6f2](https://github.com/hetznercloud/hcloud-python/commit/e3bf6f2bcdb407607d7659ca4fe54bb47f6104c5)) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 8 ++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 562ac124..f0a1aa10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v2.25.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.1) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.0...v2.25.1) + +### Bug Fixes + +- only send load balancer service health_check properties when defined (#691) ([e3bf6f2](https://github.com/hetznercloud/hcloud-python/commit/e3bf6f2bcdb407607d7659ca4fe54bb47f6104c5)) + ## [v2.25.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.0) [Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.24.0...v2.25.0) diff --git a/hcloud/_version.py b/hcloud/_version.py index 36e172a5..967db624 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.25.0" # x-releaser-pleaser-version +__version__ = "2.25.1" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index c9549fba..3da3fca4 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.25.0", # x-releaser-pleaser-version + version="2.25.1", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 76cda8ad0464178f88838736071b84995c179084 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 17 Sep 2026 10:00:05 +0200 Subject: [PATCH 57/72] test: add regression tests for load balancer service to_payload method (#693) Related to https://github.com/hetznercloud/hcloud-python/pull/691 --- tests/unit/load_balancers/test_domain.py | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 47b081f2..9889b730 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -89,3 +89,57 @@ def test_private_net_for(self): assert load_balancer.private_net_for(network1).network.id == 1 assert load_balancer.private_net_for(network3) is None + + +def test_load_balancer_service_to_payload_keeps_set_properties(): + health_check = LoadBalancerHealthCheck() + health_check.protocol = "tcp" + health_check.port = 80 + health_check.interval = 15 + health_check.timeout = 10 + health_check.retries = 3 + + service = LoadBalancerService( + protocol="tcp", + listen_port=80, + destination_port=80, + health_check=health_check, + ) + + payload = service.to_payload() + + assert payload["health_check"] == { + "protocol": "tcp", + "port": 80, + "interval": 15, + "timeout": 10, + "retries": 3, + } + + +def test_load_balancer_service_to_payload_omits_unset_properties(): + # Regression test for https://github.com/ansible-collections/hetzner.hcloud/issues/158 + # + # When only some of the health_check fields are set, the payload must not + # include the unset ones as `null`, otherwise the Hetzner Cloud API rejects + # the request with "invalid input in field 'health_check'". + health_check = LoadBalancerHealthCheck() + health_check.http = LoadBalancerHealthCheckHttp() + health_check.http.status_codes = ["404"] + health_check.http.tls = False + + service = LoadBalancerService( + protocol="https", + listen_port=443, + proxyprotocol=False, + health_check=health_check, + ) + + payload = service.to_payload() + + assert payload["health_check"] == { + "http": { + "status_codes": ["404"], + "tls": False, + }, + } From 80b74c9a48091089f18160a083c447e04cb3eda7 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:49:16 +0200 Subject: [PATCH 58/72] feat(load-balancer): add health check diagnostic details (#696) See https://docs.hetzner.cloud/changelog#2026-08-17-load-balancer-health-check-details --- hcloud/load_balancers/client.py | 2 ++ hcloud/load_balancers/domain.py | 11 +++++++++++ tests/unit/load_balancers/conftest.py | 17 +++++++++++++++-- tests/unit/load_balancers/test_client.py | 6 +++++- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 6dd4672e..e04b9130 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -130,6 +130,8 @@ def _load_balancer_target( LoadBalancerTargetHealthStatus( listen_port=item["listen_port"], status=item["status"], + detail=item.get("detail"), + http_status_code=item.get("http_status_code"), ) for item in raw_health_status ] diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 1782f9e9..bf329b92 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -488,11 +488,18 @@ class LoadBalancerTargetHealthStatus(BaseDomain): :param listen_port: Load Balancer Target listen port :param status: Load Balancer Target status. Choices: healthy, unhealthy, unknown + :param detail: Additional details about why the health check failed. Only present when `status` is `unhealthy`. + Choices: unspecified, layer4_no_connection, layer4_timeout, layer7_timeout, unexpected_http_status, + unexpected_http_content + :param http_status_code: HTTP status code returned by the target during the last health check. Only present when + `status` is `unhealthy` and `detail` is `unexpected_http_status`. """ __api_properties__ = ( "listen_port", "status", + "detail", + "http_status_code", ) __slots__ = __api_properties__ @@ -500,9 +507,13 @@ def __init__( self, listen_port: int | None = None, status: str | None = None, + detail: str | None = None, + http_status_code: int | None = None, ): self.listen_port = listen_port self.status = status + self.detail = detail + self.http_status_code = http_status_code class LoadBalancerTargetLabelSelector(BaseDomain): diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index d7fe2a90..a3675c59 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -84,7 +84,15 @@ def response_load_balancer(): { "type": "server", "server": {"id": 80}, - "health_status": [{"listen_port": 443, "status": "healthy"}], + "health_status": [ + {"listen_port": 443, "status": "healthy"}, + { + "listen_port": 80, + "status": "unhealthy", + "detail": "unexpected_http_status", + "http_status_code": 503, + }, + ], "label_selector": None, "use_private_ip": False, }, @@ -99,7 +107,12 @@ def response_load_balancer(): "use_private_ip": True, "health_status": [ {"listen_port": 443, "status": "healthy"}, - {"listen_port": 3000, "status": "healthy"}, + { + "listen_port": 3000, + "status": "unhealthy", + "detail": "unexpected_http_status", + "http_status_code": 503, + }, ], } ], diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 8ba8b4db..ea5dbfa7 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -82,8 +82,12 @@ def test_init_label_selector_nested_targets(self, response_load_balancer): assert len(nested.health_status) == 2 assert nested.health_status[0].listen_port == 443 assert nested.health_status[0].status == "healthy" + assert nested.health_status[0].detail is None + assert nested.health_status[0].http_status_code is None assert nested.health_status[1].listen_port == 3000 - assert nested.health_status[1].status == "healthy" + assert nested.health_status[1].status == "unhealthy" + assert nested.health_status[1].detail == "unexpected_http_status" + assert nested.health_status[1].http_status_code == 503 class TestLoadBalancerslient: From b150ac4632a88126d1e15421b22535d30aac521f Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:53:59 +0200 Subject: [PATCH 59/72] chore(main): release v2.26.0 (#697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - **load-balancer**: add health check diagnostic details (#696) ([80b74c9](https://github.com/hetznercloud/hcloud-python/commit/80b74c9a48091089f18160a083c447e04cb3eda7)) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 8 ++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a1aa10..8f4f9baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v2.26.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.26.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.1...v2.26.0) + +### Features + +- **load-balancer**: add health check diagnostic details (#696) ([80b74c9](https://github.com/hetznercloud/hcloud-python/commit/80b74c9a48091089f18160a083c447e04cb3eda7)) + ## [v2.25.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.1) [Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.0...v2.25.1) diff --git a/hcloud/_version.py b/hcloud/_version.py index 967db624..3be56c52 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.25.1" # x-releaser-pleaser-version +__version__ = "2.26.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 3da3fca4..b624eb92 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.25.1", # x-releaser-pleaser-version + version="2.26.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 84e3a196ff29b2fc7508bce009b7d9da38005e49 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 5 Oct 2026 15:17:36 +0200 Subject: [PATCH 60/72] feat: allow creating load balancers with primary ips (#700) Primary IPs, which were previously available exclusively for Servers, are now also available for Load Balancers. When creating a Load Balancer, you can assign an [existing Primary IP](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/list_primary_ips) instead of having an IP address allocated automatically. Currently, you cannot change the Primary IP once the Load Balancer has been created. To support this change, we added the new `public_net` property to the request body of the endpoint [POST /load_balancers](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/create_load_balancer), with the following child properties: - `public_net.ipv4`: ID of an IPv4 Primary IP to assign to the Load Balancer. - `public_net.ipv6`: ID of an IPv6 Primary IP to assign to the Load Balancer. If you omit a property, a new Primary IP of that version is allocated for the Load Balancer, as before. A Primary IP can currently only be assigned when the Load Balancer is created. It stays assigned for the entire lifetime of the Load Balancer. Related to https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips and https://docs.hetzner.cloud/changelog#2026-10-05-delete-load-balancer-returns-action. ```rp-commits feat: allow creating load balancers with primary ips (#700) feat: delete load balancer now returns an action (#700) ``` --- hcloud/load_balancers/__init__.py | 2 + hcloud/load_balancers/client.py | 47 ++++++--- hcloud/load_balancers/domain.py | 75 ++++++++++++- tests/unit/load_balancers/conftest.py | 18 +++- tests/unit/load_balancers/test_client.py | 129 ++++++++++++++++++++--- 5 files changed, 242 insertions(+), 29 deletions(-) diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 9b07d026..c68b2bbc 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -12,6 +12,7 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, @@ -36,6 +37,7 @@ "LoadBalancer", "LoadBalancerProtection", "LoadBalancerAlgorithm", + "LoadBalancerCreatePublicNetwork", "LoadBalancerHealtCheckHttp", "LoadBalancerHealthCheckHttp", "LoadBalancerHealthCheck", diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index e04b9130..a1da3925 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -19,14 +19,17 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork +from ..primary_ips import BoundPrimaryIP from ..servers import BoundServer from .domain import ( CreateLoadBalancerResponse, + DeleteLoadBalancerResponse, GetMetricsResponse, IPv4Address, IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, LoadBalancerService, @@ -72,10 +75,23 @@ def __init__( public_net = data.get("public_net") if public_net: - ipv4_address = IPv4Address.from_dict(public_net["ipv4"]) - ipv6_network = IPv6Network.from_dict(public_net["ipv6"]) + if public_net_ipv4_id := public_net["ipv4"].get("id"): + public_net["ipv4"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv4_id}, + complete=False, + ) + if public_net_ipv6_id := public_net["ipv6"].get("id"): + public_net["ipv6"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv6_id}, + complete=False, + ) + data["public_net"] = PublicNetwork( - ipv4=ipv4_address, ipv6=ipv6_network, enabled=public_net["enabled"] + ipv4=IPv4Address.from_dict(public_net["ipv4"]), + ipv6=IPv6Network.from_dict(public_net["ipv6"]), + enabled=public_net["enabled"], ) private_nets = data.get("private_net") @@ -213,11 +229,8 @@ def update( """ return self._client.update(self, name=name, labels=labels) - def delete(self) -> bool: - """Deletes a Load Balancer. - - :return: boolean - """ + def delete(self) -> DeleteLoadBalancerResponse: + """Deletes a Load Balancer.""" return self._client.delete(self) def get_metrics( @@ -514,6 +527,7 @@ def create( location: Location | BoundLocation | None = None, network_zone: str | None = None, public_interface: bool | None = None, + public_net: LoadBalancerCreatePublicNetwork | None = None, network: Network | BoundNetwork | None = None, ) -> CreateLoadBalancerResponse: """Creates a Load Balancer . @@ -536,6 +550,8 @@ def create( The targets the Load Balancer is currently serving :param public_interface: bool Enable or disable the public interface of the Load Balancer + :param public_net: + Public network configuration for the Load Balancer. :param network: Network Adds the Load Balancer to a Network :return: :class:`CreateLoadBalancerResponse ` @@ -548,6 +564,8 @@ def create( data["network"] = network.id if public_interface is not None: data["public_interface"] = public_interface + if public_net is not None: + data["public_net"] = public_net.to_payload() if labels is not None: data["labels"] = labels if algorithm is not None: @@ -595,17 +613,20 @@ def update( ) return BoundLoadBalancer(self, response["load_balancer"]) - def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: + def delete( + self, load_balancer: LoadBalancer | BoundLoadBalancer + ) -> DeleteLoadBalancerResponse: """Deletes a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :return: boolean """ - self._client.request( - url=f"{self._base_url}/{load_balancer.id}", + response = self._client.request( method="DELETE", + url=f"{self._base_url}/{load_balancer.id}", + ) + return DeleteLoadBalancerResponse( + action=BoundAction(self._parent.actions, response["action"]) ) - return True def get_metrics( self, diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index bf329b92..5a527ee0 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -12,6 +12,7 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork, Network + from ..primary_ips import BoundPrimaryIP, PrimaryIP from ..servers import BoundServer from .client import BoundLoadBalancer @@ -578,6 +579,41 @@ def __init__( self.enabled = enabled +class LoadBalancerCreatePublicNetwork(BaseDomain): + """Load Balancer Create Public Network Domain + + :param ipv4: ID of an existing Primary IP of type `ipv4` to assign to the Load Balancer. + :param ipv6: ID of an existing Primary IP of type `ipv6` to assign to the Load Balancer. + """ + + __api_properties__ = ( + "ipv4", + "ipv6", + ) + __slots__ = __api_properties__ + + def __init__( + self, + ipv4: PrimaryIP | BoundPrimaryIP | None = None, + ipv6: PrimaryIP | BoundPrimaryIP | None = None, + ): + self.ipv4 = ipv4 + self.ipv6 = ipv6 + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + + if self.ipv4 is not None: + payload["ipv4"] = self.ipv4.id + if self.ipv6 is not None: + payload["ipv6"] = self.ipv6.id + + return payload + + class IPv4Address(BaseDomain): """IPv4 Address Domain @@ -585,15 +621,25 @@ class IPv4Address(BaseDomain): The IPv4 Address """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr @@ -604,15 +650,25 @@ class IPv6Network(BaseDomain): The IPv6 Network as CIDR Notation """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr @@ -658,6 +714,21 @@ def __init__( self.action = action +class DeleteLoadBalancerResponse(BaseDomain): + """ + Delete Load Balancer Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + MetricsType = Literal[ "open_connections", "connections_per_second", diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index a3675c59..c97c56ed 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -9,8 +9,22 @@ def response_load_balancer(): "load_balancer": { "id": 4711, "name": "Web Frontend", - "ipv4": "131.232.99.1", - "ipv6": "2001:db8::1", + "public_net": { + "enabled": True, + "ipv4": { + "id": 4712, + "ip": "131.232.99.1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + "ipv6": { + "id": 4713, + "ip": "2001:db8::1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + }, + "private_net": [{"network": 4711, "ip": "10.0.255.1"}], "location": { "id": 1, "name": "fsn1", diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index ea5dbfa7..7d7fc89b 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -1,28 +1,37 @@ from __future__ import annotations +from datetime import datetime, timezone from unittest import mock import pytest from hcloud import Client -from hcloud.load_balancer_types import LoadBalancerType +from hcloud.certificates import BoundCertificate +from hcloud.load_balancer_types import BoundLoadBalancerType, LoadBalancerType from hcloud.load_balancers import ( BoundLoadBalancer, + IPv4Address, + IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancersClient, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + PrivateNet, + PublicNetwork, ) -from hcloud.locations import Location -from hcloud.networks import Network +from hcloud.locations import BoundLocation, Location +from hcloud.networks import BoundNetwork, Network +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP from hcloud.servers import BoundServer, Server -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 class TestBoundLoadBalancer(BoundModelTestCase): @@ -53,13 +62,99 @@ def resource_client(self, client: Client): def bound_model(self, resource_client: LoadBalancersClient): return BoundLoadBalancer(resource_client, data=dict(id=1)) + # pylint: disable=too-many-statements def test_init(self, response_load_balancer): - bound_load_balancer = BoundLoadBalancer( + o = BoundLoadBalancer( client=mock.MagicMock(), data=response_load_balancer["load_balancer"] ) - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" + assert o.id == 4711 + assert o.name == "Web Frontend" + assert o.created == datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + assert o.labels == {} + assert o.protection == {"delete": False} + assert o.outgoing_traffic == 123456 + assert o.ingoing_traffic == 123456 + assert o.included_traffic == 654321 + + assert isinstance(o.public_net, PublicNetwork) + assert o.public_net.enabled is True + assert isinstance(o.public_net.ipv4, IPv4Address) + assert isinstance(o.public_net.ipv4.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv4.primary_ip.id == 4712 + assert o.public_net.ipv4.primary_ip.complete is False + assert o.public_net.ipv4.ip == "131.232.99.1" + assert o.public_net.ipv4.blocked is False + assert o.public_net.ipv4.dns_ptr == "lb1.example.com" + assert isinstance(o.public_net.ipv6, IPv6Network) + assert isinstance(o.public_net.ipv6.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv6.primary_ip.id == 4713 + assert o.public_net.ipv6.primary_ip.complete is False + assert o.public_net.ipv6.ip == "2001:db8::1" + assert o.public_net.ipv6.blocked is False + assert o.public_net.ipv6.dns_ptr == "lb1.example.com" + + assert len(o.private_net) == 1 + assert isinstance(o.private_net[0], PrivateNet) + assert isinstance(o.private_net[0].network, BoundNetwork) + assert o.private_net[0].network.id == 4711 + assert o.private_net[0].network.complete is False + assert o.private_net[0].ip == "10.0.255.1" + + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.location.complete is True + + assert isinstance(o.load_balancer_type, BoundLoadBalancerType) + assert o.load_balancer_type.id == 1 + assert o.load_balancer_type.name == "lb11" + assert o.load_balancer_type.complete is True + + assert isinstance(o.algorithm, LoadBalancerAlgorithm) + assert o.algorithm.type == "round_robin" + + assert len(o.services) == 1 + service = o.services[0] + assert isinstance(service, LoadBalancerService) + assert service.protocol == "https" + assert service.listen_port == 443 + assert service.destination_port == 80 + assert service.proxyprotocol is False + assert isinstance(service.http, LoadBalancerServiceHttp) + assert service.http.cookie_name == "HCLBSTICKY" + assert service.http.cookie_lifetime == 300 + assert service.http.redirect_http is True + assert service.http.sticky_sessions is True + assert service.http.timeout_idle == 60 + assert len(service.http.certificates) == 1 + assert isinstance(service.http.certificates[0], BoundCertificate) + assert service.http.certificates[0].id == 897 + assert service.http.certificates[0].complete is False + assert isinstance(service.health_check, LoadBalancerHealthCheck) + assert service.health_check.protocol == "http" + assert service.health_check.port == 4711 + assert service.health_check.interval == 15 + assert service.health_check.timeout == 10 + assert service.health_check.retries == 3 + assert isinstance(service.health_check.http, LoadBalancerHealthCheckHttp) + assert service.health_check.http.domain == "example.com" + assert service.health_check.http.path == "/" + assert service.health_check.http.response == '{"status": "ok"}' + assert service.health_check.http.status_codes == [200] + assert service.health_check.http.tls is False + + assert len(o.targets) == 2 + target = o.targets[0] + assert isinstance(target, LoadBalancerTarget) + assert target.type == "server" + assert isinstance(target.server, BoundServer) + assert target.server.id == 80 + assert target.server.complete is False + assert target.use_private_ip is False + assert len(target.health_status) == 2 + assert target.health_status[0].listen_port == 443 + assert target.health_status[0].status == "healthy" def test_init_label_selector_nested_targets(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( @@ -231,12 +326,22 @@ def test_create( "my-balancer", load_balancer_type=LoadBalancerType(name="lb11"), location=Location(id=1), + public_net=LoadBalancerCreatePublicNetwork( + ipv6=PrimaryIP(id=56367), + ), ) request_mock.assert_called_with( method="POST", url="/load_balancers", - json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + json={ + "name": "my-balancer", + "load_balancer_type": "lb11", + "location": 1, + "public_net": { + "ipv6": 56367, + }, + }, ) bound_load_balancer = response.load_balancer @@ -330,18 +435,18 @@ def test_delete( request_mock: mock.MagicMock, resource_client: LoadBalancersClient, load_balancer, - action_response, + action1_running, ): - request_mock.return_value = action_response + request_mock.return_value = {"action": action1_running} - delete_success = resource_client.delete(load_balancer) + result = resource_client.delete(load_balancer) request_mock.assert_called_with( method="DELETE", url="/load_balancers/1", ) - assert delete_success is True + assert_bound_action1(result.action, resource_client._parent.actions) @pytest.mark.parametrize( "load_balancer", From a2e801215c782b1aeb0dadec87a7b5d3b7d6a5b1 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:25:46 +0200 Subject: [PATCH 61/72] chore(main): release v2.27.0 (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Load Balancers can now be created with existing Primary IPs Primary IPs, which were previously available exclusively for Servers, are now also available for Load Balancers. When creating a Load Balancer, you can assign an [existing Primary IP](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/list_primary_ips) instead of having an IP address allocated automatically. Currently, you cannot change the Primary IP once the Load Balancer has been created. To support this change, we added the new `public_net` property to the request body of the endpoint [POST /load_balancers](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/create_load_balancer), with the following child properties: - `public_net.ipv4`: ID of an IPv4 Primary IP to assign to the Load Balancer. - `public_net.ipv6`: ID of an IPv6 Primary IP to assign to the Load Balancer. If you omit a property, a new Primary IP of that version is allocated for the Load Balancer, as before. A Primary IP can currently only be assigned when the Load Balancer is created. It stays assigned for the entire lifetime of the Load Balancer. Related to https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips. ### Deleting a Load Balancer now returns an Action > [!IMPORTANT] > Please update all code that uses the `LoadBalancersClient.delete` method to wait on the newly returned action. As part of our work on supporting [existing Primary IPs for Load Balancers](https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips), deleting a Load Balancer is now tracked with an Action. The endpoint [DELETE /load_balancers/{id}](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/delete_load_balancer) now returns a 201 status code with an Action in the response body, instead of a 204 status code with an empty response body. You can use the returned Action to track the progress of the deletion. Related to https://docs.hetzner.cloud/changelog#2026-10-05-delete-load-balancer-returns-action. ### Features - allow creating load balancers with primary ips (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) - delete load balancer now returns an action (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) ---

PR by releaser-pleaser 🤖

If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. ## Release Notes ### Prefix / Start This will be added to the start of the release notes. ~~~~rp-prefix ### Load Balancers can now be created with existing Primary IPs Primary IPs, which were previously available exclusively for Servers, are now also available for Load Balancers. When creating a Load Balancer, you can assign an [existing Primary IP](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/list_primary_ips) instead of having an IP address allocated automatically. Currently, you cannot change the Primary IP once the Load Balancer has been created. To support this change, we added the new `public_net` property to the request body of the endpoint [POST /load_balancers](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/create_load_balancer), with the following child properties: - `public_net.ipv4`: ID of an IPv4 Primary IP to assign to the Load Balancer. - `public_net.ipv6`: ID of an IPv6 Primary IP to assign to the Load Balancer. If you omit a property, a new Primary IP of that version is allocated for the Load Balancer, as before. A Primary IP can currently only be assigned when the Load Balancer is created. It stays assigned for the entire lifetime of the Load Balancer. Related to https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips. ### Deleting a Load Balancer now returns an Action > [!IMPORTANT] > Please update all code that uses the `LoadBalancersClient.delete` method to wait on the newly returned action. As part of our work on supporting [existing Primary IPs for Load Balancers](https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips), deleting a Load Balancer is now tracked with an Action. The endpoint [DELETE /load_balancers/{id}](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/delete_load_balancer) now returns a 201 status code with an Action in the response body, instead of a 204 status code with an empty response body. You can use the returned Action to track the progress of the deletion. Related to https://docs.hetzner.cloud/changelog#2026-10-05-delete-load-balancer-returns-action. ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f4f9baf..795fe77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [v2.27.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.27.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.26.0...v2.27.0) + +### Load Balancers can now be created with existing Primary IPs + +Primary IPs, which were previously available exclusively for Servers, are now also available for Load Balancers. When creating a Load Balancer, you can assign an [existing Primary IP](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/list_primary_ips) instead of having an IP address allocated automatically. Currently, you cannot change the Primary IP once the Load Balancer has been created. + +To support this change, we added the new `public_net` property to the request body of the endpoint [POST /load_balancers](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/create_load_balancer), with the following child properties: + +- `public_net.ipv4`: ID of an IPv4 Primary IP to assign to the Load Balancer. +- `public_net.ipv6`: ID of an IPv6 Primary IP to assign to the Load Balancer. + +If you omit a property, a new Primary IP of that version is allocated for the Load Balancer, as before. + +A Primary IP can currently only be assigned when the Load Balancer is created. It stays assigned for the entire lifetime of the Load Balancer. + +Related to https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips. + +### Deleting a Load Balancer now returns an Action + +> [!IMPORTANT] +> Please update all code that uses the `LoadBalancersClient.delete` method to wait on the newly returned action. + +As part of our work on supporting [existing Primary IPs for Load Balancers](https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips), deleting a Load Balancer is now tracked with an Action. + +The endpoint [DELETE /load_balancers/{id}](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/delete_load_balancer) now returns a 201 status code with an Action in the response body, instead of a 204 status code with an empty response body. + +You can use the returned Action to track the progress of the deletion. + +Related to https://docs.hetzner.cloud/changelog#2026-10-05-delete-load-balancer-returns-action. + +### Features + +- allow creating load balancers with primary ips (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) +- delete load balancer now returns an action (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) + ## [v2.26.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.26.0) [Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.1...v2.26.0) diff --git a/hcloud/_version.py b/hcloud/_version.py index 3be56c52..25e7bb72 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.26.0" # x-releaser-pleaser-version +__version__ = "2.27.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index b624eb92..9412b104 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.26.0", # x-releaser-pleaser-version + version="2.27.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 6c689ed27a0395cdc611b4a2357bb2f9fefdc8e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:48:21 +0200 Subject: [PATCH 62/72] chore(deps): update pre-commit hook jooola/pre-commit-prettier to v3.9.10 (#694) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8d052a2a..4f46d79a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: - id: trailing-whitespace - repo: https://github.com/jooola/pre-commit-prettier - rev: 3.9.6 + rev: 3.9.10 hooks: - id: prettier files: \.(md|ya?ml|js|css)$ From a4001551eced2ea73d06af6ce1c83e77b55b1177 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:48:42 +0200 Subject: [PATCH 63/72] chore(deps): update dependency mypy to >=2.4,<2.5 (#702) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9412b104..c8fc0790 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", - "mypy>=2.3,<2.4", + "mypy>=2.4,<2.5", "types-python-dateutil", "types-requests", ], From 23402cfa60d9e77699f0bf1652ca9ac950b86c08 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:48:59 +0200 Subject: [PATCH 64/72] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v26.10.1 (#703) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4f46d79a..80f3b873 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,7 +40,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.5.1 + rev: 26.10.1 hooks: - id: black From ac543cda451f5989bde97178f5dbe3b4029d0b68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:49:21 +0200 Subject: [PATCH 65/72] chore(deps): update pre-commit hook asottile/pyupgrade to v3.22.0 (#704) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 80f3b873..898f2c4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: files: \.(md|ya?ml|js|css)$ - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py310-plus] From 194940af027a4c74a6abfba4117ded57e9f807e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:50:06 +0200 Subject: [PATCH 66/72] chore(deps): update github actions (#705) --- .github/workflows/release.yml | 4 ++-- .github/workflows/test.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13ce6a26..3e0d18f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Upload packages artifact if: github.event_name == 'release' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 with: name: python-packages path: dist/ @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: python-packages path: dist/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a280769f..3ab6ad3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,6 +37,6 @@ jobs: if: > !startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'releaser-pleaser--') - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: token: ${{ secrets.CODECOV_TOKEN }} From 1c3d908eb57de1ff687ced0f8c602cbc22609939 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:51:11 +0200 Subject: [PATCH 67/72] chore(deps): update pre-commit hook pycqa/isort to v9.0.2 (#698) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 898f2c4c..c0423c65 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--py310-plus] - repo: https://github.com/pycqa/isort - rev: 9.0.1 + rev: 9.0.2 hooks: - id: isort From 052cf3bdd45ecf974cec13346bcb49a56f4a8e51 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:53:37 +0200 Subject: [PATCH 68/72] chore(deps): update pre-commit hook pycqa/flake8 to v7.4.1 (#695) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c0423c65..2a6d6301 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -45,6 +45,6 @@ repos: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 From c94a66d87042c4d196c1076947426eb4bcdc4a1a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:11:54 +0200 Subject: [PATCH 69/72] chore(deps): update dependency pylint to >=4,<4.2 (#699) --- hcloud/servers/client.py | 4 ++-- setup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 68cbb9ce..ce5a72c0 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -348,11 +348,11 @@ def create_image( self, description=description, type=type, labels=labels ) + # pylint: disable=unused-argument def rebuild( self, image: Image | BoundImage, user_data: str | None = None, - # pylint: disable=unused-argument **kwargs: Any, ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. @@ -1016,12 +1016,12 @@ def create_image( image=BoundImage(self._parent.images, response["image"]), ) + # pylint: disable=unused-argument def rebuild( self, server: Server | BoundServer, image: Image | BoundImage, user_data: str | None = None, - # pylint: disable=unused-argument **kwargs: Any, ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. diff --git a/setup.py b/setup.py index c8fc0790..bbcd4774 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ ], "test": [ "coverage>=7.16,<7.17", - "pylint>=4,<4.1", + "pylint>=4,<4.2", "pytest>=9,<9.2", "pytest-cov>=7,<7.2", "mypy>=2.4,<2.5", From e7b537cad698a8205b27f3a1fd220304e4ece150 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 22 Oct 2026 12:45:52 +0200 Subject: [PATCH 70/72] ci: pin python version to 3.14 (#707) Fixes https://github.com/hetznercloud/hcloud-python/actions/runs/39764640106/job/119776947325?pr=706 and is a better practice. --- .github/workflows/lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3c62238d..bd56f643 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - name: Setup python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: pip install pre-commit @@ -30,7 +30,7 @@ jobs: - name: Setup python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: make venv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e0d18f8..bf955cc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: pip install build twine From 605a2602f753760977b224a404a7a5aba46d6d09 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 22 Oct 2026 12:49:20 +0200 Subject: [PATCH 71/72] feat: drop support for python 3.10 (#706) Python 3.10 is EOL since Oct 2026. https://devguide.python.org/versions/ --- .github/workflows/test.yml | 1 - .gitlab-ci.yml | 1 - .pre-commit-config.yaml | 2 +- pyproject.toml | 2 +- setup.py | 3 +-- tox.ini | 3 +-- 6 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3ab6ad3a..84249780 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,6 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f2cc23b9..5ed2acfd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,7 +27,6 @@ test: parallel: matrix: - python_version: - - "3.10" - "3.11" - "3.12" - "3.13" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2a6d6301..157f3753 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: rev: v3.22.0 hooks: - id: pyupgrade - args: [--py310-plus] + args: [--py311-plus] - repo: https://github.com/pycqa/isort rev: 9.0.2 diff --git a/pyproject.toml b/pyproject.toml index cbf37daf..8bb90a3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pylint.main] -py-version = "3.10" +py-version = "3.11" recursive = true jobs = 0 diff --git a/setup.py b/setup.py index bbcd4774..afbdd9ab 100644 --- a/setup.py +++ b/setup.py @@ -27,13 +27,12 @@ "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ], - python_requires=">=3.10", + python_requires=">=3.11", install_requires=[ "python-dateutil>=2.7.5", "requests>=2.20", diff --git a/tox.ini b/tox.ini index 813895b3..2cd65d13 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py310, py311, py312, py313, py314 +envlist = py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT @@ -10,7 +10,6 @@ commands = [gh-actions] python = - 3.10: py310 3.11: py311 3.12: py312 3.13: py313 From 62cbd25d87ae91dcae89633764484f1bae795269 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 22 Oct 2026 12:53:48 +0200 Subject: [PATCH 72/72] feat: support python 3.15 (#709) Adds support for the newly released Python 3.15. --- .github/workflows/test.yml | 1 + .gitlab-ci.yml | 1 + setup.py | 1 + tox.ini | 3 ++- 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84249780..6759f991 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,7 @@ jobs: - "3.12" - "3.13" - "3.14" + - "3.15" name: Python ${{ matrix.python-version }} steps: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ed2acfd..820385d0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -31,6 +31,7 @@ test: - "3.12" - "3.13" - "3.14" + - "3.15" image: python:${python_version}-alpine before_script: diff --git a/setup.py b/setup.py index afbdd9ab..e1988103 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", ], python_requires=">=3.11", install_requires=[ diff --git a/tox.ini b/tox.ini index 2cd65d13..2bb22fa9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py311, py312, py313, py314 +envlist = py311, py312, py313, py314, py315 [testenv] passenv = FAKE_API_ENDPOINT @@ -14,3 +14,4 @@ python = 3.12: py312 3.13: py313 3.14: py314 + 3.15: py315