From 96216048c9ba13b6286d584c2dd0ec440f484105 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 19 Aug 2024 20:03:43 +0200 Subject: [PATCH 001/213] fix: prices properties are list of dict (#438) Closes #437 --- hcloud/load_balancer_types/domain.py | 4 ++-- hcloud/server_types/domain.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hcloud/load_balancer_types/domain.py b/hcloud/load_balancer_types/domain.py index 6ef4c284..9142cb58 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -20,7 +20,7 @@ class LoadBalancerType(BaseDomain, DomainIdentityMixin): Max amount of targets the Load Balancer can handle :param max_assigned_certificates: int Max amount of certificates the Load Balancer can serve - :param prices: Dict + :param prices: List of dict Prices in different locations """ @@ -46,7 +46,7 @@ def __init__( max_services: int | None = None, max_targets: int | None = None, max_assigned_certificates: int | None = None, - prices: dict | None = None, + prices: list[dict] | None = None, ): self.id = id self.name = name diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index d026f866..c2031c6a 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -21,7 +21,7 @@ class ServerType(BaseDomain, DomainIdentityMixin): Memory a server of this type will have in GB :param disk: int Disk size a server of this type will have in GB - :param prices: Dict + :param prices: List of dict Prices in different locations :param storage_type: str Type of server boot drive. Local has higher speed. Network has better availability. Choices: `local`, `network` @@ -69,7 +69,7 @@ def __init__( cores: int | None = None, memory: int | None = None, disk: int | None = None, - prices: dict | None = None, + prices: list[dict] | None = None, storage_type: str | None = None, cpu_type: str | None = None, architecture: str | None = None, From 2fd291562818e93263ffb995bfa8af0bb4de387e Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 19 Aug 2024 20:39:38 +0200 Subject: [PATCH 002/213] chore(main): release 2.2.1 (#439) --- .github/release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json index 33bf7bf8..e8554c22 100644 --- a/.github/release-please-manifest.json +++ b/.github/release-please-manifest.json @@ -1 +1 @@ -{".":"2.2.0"} +{".":"2.2.1"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d9cb03f..5698c95f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [2.2.1](https://github.com/hetznercloud/hcloud-python/compare/v2.2.0...v2.2.1) (2024-08-19) + + +### Bug Fixes + +* prices properties are list of dict ([#438](https://github.com/hetznercloud/hcloud-python/issues/438)) ([9621604](https://github.com/hetznercloud/hcloud-python/commit/96216048c9ba13b6286d584c2dd0ec440f484105)), closes [#437](https://github.com/hetznercloud/hcloud-python/issues/437) + ## [2.2.0](https://github.com/hetznercloud/hcloud-python/compare/v2.1.1...v2.2.0) (2024-08-06) diff --git a/hcloud/_version.py b/hcloud/_version.py index 1fc7f59e..dfcddff3 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.2.0" # x-release-please-version +__version__ = "2.2.1" # x-release-please-version diff --git a/setup.py b/setup.py index 1e2cd26d..ff02426f 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.2.0", + version="2.2.1", keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 0d7631c3dd38b362098e95bbabbe4f1cb99c5683 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 27 Aug 2024 08:11:42 +0200 Subject: [PATCH 003/213] chore(deps): update dependency watchdog to v5 (#440) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ff02426f..6d70e837 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ "sphinx>=7.4,<7.5", "sphinx-rtd-theme>=2,<2.1", "myst-parser>=4,<4.1", - "watchdog>=4,<4.1", + "watchdog>=5,<5.1", ], "test": [ "coverage>=7.6,<7.7", From 78c1cfc9b876b1d1e8652b2fa42243bcc21a9749 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 11:10:38 +0200 Subject: [PATCH 004/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.10.1 (#441) --- .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 150e7164..9a39dbc4 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.9.0 + uses: pypa/gh-action-pypi-publish@v1.10.1 From a66d6d679d3c007b8cbfa06240620cba27b76bf3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 21 Sep 2024 01:06:32 +0000 Subject: [PATCH 005/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.10.2 --- .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 9a39dbc4..91c2e99f 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.10.1 + uses: pypa/gh-action-pypi-publish@v1.10.2 From 66ccd2abb62ec3fc3c90476fdb10b04a5a155634 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 4 Oct 2024 04:21:05 +0000 Subject: [PATCH 006/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.10.3 --- .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 91c2e99f..4931b7ee 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.10.2 + uses: pypa/gh-action-pypi-publish@v1.10.3 From 20c32d545c0773fbe1254e057a5958e25c672839 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 22:23:17 +0000 Subject: [PATCH 007/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v24.10.0 --- .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 31228f92..efcbc32c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.8.0 + rev: 24.10.0 hooks: - id: black From 1f6da4ef243321d3c6850b876f3c11fb1195edcf Mon Sep 17 00:00:00 2001 From: Benjamin Koltermann <48812495+p4ck3t0@users.noreply.github.com> Date: Tue, 8 Oct 2024 12:36:41 +0200 Subject: [PATCH 008/213] fix: change floating ip labels type to `dict[str, str]` (#444) The type for the floating ip label was wrong. Its now changed to the type in the comments and works as expected. --- hcloud/floating_ips/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 00600e48..a7669147 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -293,7 +293,7 @@ def create( self, type: str, description: str | None = None, - labels: str | None = None, + labels: dict[str, str] | None = None, home_location: Location | BoundLocation | None = None, server: Server | BoundServer | None = None, name: str | None = None, From 4db0e2054ac5db37c568edbfd442e404e7bad290 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:23:17 +0200 Subject: [PATCH 009/213] chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v5 (#446) --- .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 efcbc32c..8aea6ebe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-added-large-files - id: check-case-conflict From ec2c02bdcf6634de2bba1a6d170ab2a266512c8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:42:02 +0200 Subject: [PATCH 010/213] chore(deps): update dependency sphinx-rtd-theme to v3 (#447) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6d70e837..9273e5d3 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ extras_require={ "docs": [ "sphinx>=7.4,<7.5", - "sphinx-rtd-theme>=2,<2.1", + "sphinx-rtd-theme>=3,<3.1", "myst-parser>=4,<4.1", "watchdog>=5,<5.1", ], From 93a56414cd3ca92f3a7c033643cc386ac3fa03e1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:46:03 +0200 Subject: [PATCH 011/213] chore(deps): update dependency sphinx to v8 (#428) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9273e5d3..350d077b 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ ], extras_require={ "docs": [ - "sphinx>=7.4,<7.5", + "sphinx>=8,<8.1", "sphinx-rtd-theme>=3,<3.1", "myst-parser>=4,<4.1", "watchdog>=5,<5.1", From da0dc98e889a2e1d339708f021319f3199cf5bf2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:51:10 +0200 Subject: [PATCH 012/213] chore(deps): update dependency pylint to >=3,<3.4 (#442) --- pyproject.toml | 1 + setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 60a7dd06..f88f9b25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,4 +35,5 @@ disable = [ "too-many-arguments", "too-many-instance-attributes", "too-many-lines", + "too-many-positional-arguments", ] diff --git a/setup.py b/setup.py index 350d077b..39b7472b 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ ], "test": [ "coverage>=7.6,<7.7", - "pylint>=3,<3.3", + "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=5,<5.1", "mypy>=1.11,<1.12", From 4a514c7a1136a4a8c592c77120c5be36cd221b33 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 9 Oct 2024 14:49:31 +0200 Subject: [PATCH 013/213] feat: support python 3.13 (#451) Adds support for the newly released Python 3.13. --- .github/workflows/test.yml | 2 +- .gitlab-ci.yml | 2 +- setup.py | 1 + tox.ini | 1 + 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 415c2200..1110c625 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] name: Python ${{ matrix.python-version }} steps: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 40773eed..75924dd4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,7 @@ test: parallel: matrix: - - python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + - python_version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] image: python:${python_version}-alpine before_script: diff --git a/setup.py b/setup.py index 39b7472b..0ca3fc0e 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ], python_requires=">=3.8", install_requires=[ diff --git a/tox.ini b/tox.ini index cf17b6de..32a4b446 100644 --- a/tox.ini +++ b/tox.ini @@ -15,3 +15,4 @@ python = 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 From 64b48efe7669cf7ec99023dc8f800949c8c8b687 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:50:00 +0200 Subject: [PATCH 014/213] chore(deps): update python docker tag to v3.13 (#449) --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 75924dd4..6dda2f91 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ pre-commit: lint: stage: test - image: python:3.12-alpine + image: python:3.13-alpine before_script: - apk add make bash - make venv From 55e451366019d6fbe477635b1062d7739d3457e2 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:52:37 +0200 Subject: [PATCH 015/213] chore(main): release 2.3.0 (#450) :robot: I have created a release *beep* *boop* --- ## [2.3.0](https://github.com/hetznercloud/hcloud-python/compare/v2.2.1...v2.3.0) (2024-10-09) ### Features * support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) ### Bug Fixes * change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). --- .github/release-please-manifest.json | 2 +- CHANGELOG.md | 12 ++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json index e8554c22..4b36daa1 100644 --- a/.github/release-please-manifest.json +++ b/.github/release-please-manifest.json @@ -1 +1 @@ -{".":"2.2.1"} +{".":"2.3.0"} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5698c95f..3416f053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [2.3.0](https://github.com/hetznercloud/hcloud-python/compare/v2.2.1...v2.3.0) (2024-10-09) + + +### Features + +* support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) + + +### Bug Fixes + +* change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) + ## [2.2.1](https://github.com/hetznercloud/hcloud-python/compare/v2.2.0...v2.2.1) (2024-08-19) diff --git a/hcloud/_version.py b/hcloud/_version.py index dfcddff3..bb279990 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.2.1" # x-release-please-version +__version__ = "2.3.0" # x-release-please-version diff --git a/setup.py b/setup.py index 0ca3fc0e..ee10fc93 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.2.1", + version="2.3.0", keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 4fcf3d31ec7d583f6df48dfd2d8ee47735d0ed43 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Oct 2024 14:22:20 +0200 Subject: [PATCH 016/213] chore(deps): update dependency sphinx to >=8,<8.2 (#452) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index ee10fc93..81d242a1 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ ], extras_require={ "docs": [ - "sphinx>=8,<8.1", + "sphinx>=8,<8.2", "sphinx-rtd-theme>=3,<3.1", "myst-parser>=4,<4.1", "watchdog>=5,<5.1", From ce2b3361eb6c7b37ae772958c148a89a31d867a5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 12 Oct 2024 00:20:07 +0000 Subject: [PATCH 017/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.18.0 --- .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 8aea6ebe..35eba8cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade - rev: v3.17.0 + rev: v3.18.0 hooks: - id: pyupgrade args: [--py38-plus] From d2cfee61167857bfe3ff88a441622da2c913810c Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 21 Oct 2024 09:56:06 +0200 Subject: [PATCH 018/213] fix: server public ipv4 and ipv6 properties are nullable (#455) Fix the incorrect typing that was caught in #454 --- hcloud/servers/domain.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index 4f9d80db..368464c4 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -293,8 +293,8 @@ class PublicNetwork(BaseDomain): def __init__( self, - ipv4: IPv4Address, - ipv6: IPv6Network, + ipv4: IPv4Address | None, + ipv6: IPv6Network | None, floating_ips: list[BoundFloatingIP], primary_ipv4: BoundPrimaryIP | None, primary_ipv6: BoundPrimaryIP | None, From b5a633ed35be2c5daec0c57e88cbb9d23ea2ee19 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 09:58:31 +0200 Subject: [PATCH 019/213] chore(deps): update dependency mypy to >=1.12,<1.13 (#454) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 81d242a1..5a26ebf5 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=5,<5.1", - "mypy>=1.11,<1.12", + "mypy>=1.12,<1.13", "types-python-dateutil", "types-requests", ], From 7408c9dab39f7d62d51d9c9c19d5fb050ee30e14 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 22 Oct 2024 08:26:24 +0200 Subject: [PATCH 020/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.19.0 (#457) --- .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 35eba8cd..a3e73cba 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade - rev: v3.18.0 + rev: v3.19.0 hooks: - id: pyupgrade args: [--py38-plus] From a37aff4fef42c21cc6053d101e267f87302c393b Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 22 Oct 2024 15:48:09 +0200 Subject: [PATCH 021/213] chore: add missing py313 in tox env list (#459) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 32a4b446..aa659216 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311, py312 +envlist = py38, py39, py310, py311, py312, py313 [testenv] passenv = FAKE_API_ENDPOINT From c9fb19503be608f955b6fb0f14ddc68d58097c91 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 23 Oct 2024 08:54:34 +0200 Subject: [PATCH 022/213] chore(deps): update dependency mypy to >=1.13,<1.14 (#460) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5a26ebf5..78eef5f3 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=5,<5.1", - "mypy>=1.12,<1.13", + "mypy>=1.13,<1.14", "types-python-dateutil", "types-requests", ], From 342bfe0619d6f4e973b1e093dec4369a0818bea6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 31 Oct 2024 10:17:01 +0100 Subject: [PATCH 023/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.11.0 (#462) --- .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 4931b7ee..31e5f609 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.10.3 + uses: pypa/gh-action-pypi-publish@v1.11.0 From aca4a8ae29eb548adb77e509826ebd9181f1d84d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Nov 2024 16:34:58 +0100 Subject: [PATCH 024/213] chore(deps): update dependency watchdog to v6 (#463) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 78eef5f3..a42f639e 100644 --- a/setup.py +++ b/setup.py @@ -45,7 +45,7 @@ "sphinx>=8,<8.2", "sphinx-rtd-theme>=3,<3.1", "myst-parser>=4,<4.1", - "watchdog>=5,<5.1", + "watchdog>=6,<6.1", ], "test": [ "coverage>=7.6,<7.7", From e74e3195f74cb5541b2d664c7d1812e05f4ac229 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 5 Nov 2024 16:40:47 +0100 Subject: [PATCH 025/213] feat: drop support for python 3.8 (#458) Python 3.8 is EOL since 07 Oct 2024. https://devguide.python.org/versions/ --- .github/workflows/test.yml | 2 +- .gitlab-ci.yml | 2 +- .pre-commit-config.yaml | 2 +- hcloud/metrics/domain.py | 4 ++-- pyproject.toml | 2 +- setup.py | 3 +-- tox.ini | 3 +-- 7 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1110c625..ff4ccc65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] name: Python ${{ matrix.python-version }} steps: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6dda2f91..4cef0733 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,7 @@ test: parallel: matrix: - - python_version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + - python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] image: python:${python_version}-alpine before_script: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3e73cba..11750705 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: rev: v3.19.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] - repo: https://github.com/pycqa/isort rev: 5.13.2 diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py index cf655713..c7e1f55a 100644 --- a/hcloud/metrics/domain.py +++ b/hcloud/metrics/domain.py @@ -1,13 +1,13 @@ from __future__ import annotations from datetime import datetime -from typing import Dict, List, Literal, Tuple +from typing import Literal from dateutil.parser import isoparse from ..core import BaseDomain -TimeSeries = Dict[str, Dict[Literal["values"], List[Tuple[float, str]]]] +TimeSeries = dict[str, dict[Literal["values"], list[tuple[float, str]]]] class Metrics(BaseDomain): diff --git a/pyproject.toml b/pyproject.toml index f88f9b25..d1bf2ad1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pylint.main] -py-version = "3.8" +py-version = "3.9" recursive = true jobs = 0 diff --git a/setup.py b/setup.py index a42f639e..afa07e9d 100644 --- a/setup.py +++ b/setup.py @@ -28,14 +28,13 @@ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ], - python_requires=">=3.8", + python_requires=">=3.9", install_requires=[ "python-dateutil>=2.7.5", "requests>=2.20", diff --git a/tox.ini b/tox.ini index aa659216..5e316e4e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311, py312, py313 +envlist = py39, py310, py311, py312, py313 [testenv] passenv = FAKE_API_ENDPOINT @@ -10,7 +10,6 @@ commands = [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 From e2969ad6d900560deac062390fe3534952bc12d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 5 Nov 2024 17:06:36 +0100 Subject: [PATCH 026/213] chore(deps): update dependency pytest-cov to v6 (#461) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index afa07e9d..fa7bb699 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "coverage>=7.6,<7.7", "pylint>=3,<3.4", "pytest>=8,<8.4", - "pytest-cov>=5,<5.1", + "pytest-cov>=6,<6.1", "mypy>=1.13,<1.14", "types-python-dateutil", "types-requests", From 206995267fb48ab349eeb207a84ce9bdf6b3d810 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 12 Nov 2024 10:25:14 +0100 Subject: [PATCH 027/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.12.2 (#464) --- .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 31e5f609..21f808c7 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.11.0 + uses: pypa/gh-action-pypi-publish@v1.12.2 From a9c179dc9655273d6570622fadad6e61abfa7812 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 10:21:37 +0100 Subject: [PATCH 028/213] chore(deps): update codecov/codecov-action action to v5 (#465) --- .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 ff4ccc65..5949e58f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,6 @@ jobs: if: > !startsWith(github.head_ref, 'renovate/') && !startsWith(github.head_ref, 'release-please--') - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} From 706abf1efbef1051a59b536420dae63e7e21b182 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 19:10:00 +0100 Subject: [PATCH 029/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.12.3 (#466) --- .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 21f808c7..61ca6a8c 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.12.2 + uses: pypa/gh-action-pypi-publish@v1.12.3 From b2fae541592ddf74ae9ecc54f34d75b6bfecf19a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:06:44 +0000 Subject: [PATCH 030/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.19.1 (#467) 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 | |---|---|---|---| | [asottile/pyupgrade](https://redirect.github.com/asottile/pyupgrade) | repository | patch | `v3.19.0` -> `v3.19.1` | 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
asottile/pyupgrade (asottile/pyupgrade) ### [`v3.19.1`](https://redirect.github.com/asottile/pyupgrade/compare/v3.19.0...v3.19.1) [Compare Source](https://redirect.github.com/asottile/pyupgrade/compare/v3.19.0...v3.19.1)
--- ### 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 11750705..f6abcc5a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + rev: v3.19.1 hooks: - id: pyupgrade args: [--py39-plus] From 3797fd00625b5cc7d5fbbb5f3d17a73936bcb851 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:00:32 +0000 Subject: [PATCH 031/213] chore(deps): update dependency mypy to >=1.14,<1.15 (#468) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fa7bb699..fdc29213 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=6,<6.1", - "mypy>=1.13,<1.14", + "mypy>=1.14,<1.15", "types-python-dateutil", "types-requests", ], From 228e20e70170ab444c92c361bb597f0080d8664d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 08:36:08 +0000 Subject: [PATCH 032/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.12.4 (#470) 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 | |---|---|---|---| | [pypa/gh-action-pypi-publish](https://redirect.github.com/pypa/gh-action-pypi-publish) | action | patch | `v1.12.3` -> `v1.12.4` | --- ### Release Notes
pypa/gh-action-pypi-publish (pypa/gh-action-pypi-publish) ### [`v1.12.4`](https://redirect.github.com/pypa/gh-action-pypi-publish/releases/tag/v1.12.4) [Compare Source](https://redirect.github.com/pypa/gh-action-pypi-publish/compare/v1.12.3...v1.12.4)

Be nice to FOSS maintainers.

##### ✨ What's Changed The main theme of this patch release that the support for uploading [PEP 639 licensing metadata] to PyPI has been fixed in [#​327](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/327). ##### πŸ› οΈ Internal Updates A few smaller updates include the attestation existence being checked earlier in the process now, listing all the violating files together, not just one (PR [#​315](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/315)). And the lock file with the software available in runtime has been re-pinned in [#​329](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/329). Additionally, the CI now runs the smoke-tests against both Ubuntu 22.04 and 24.04 explicitly via [`da900af`](https://redirect.github.com/pypa/gh-action-pypi-publish/commit/da900af96347cc027433720ad4f122117645459d). **πŸͺž Full Diff**: https://github.com/pypa/gh-action-pypi-publish/compare/v1.12.3...v1.12.4 **πŸ§”β€β™‚οΈ Release Manager:** [@​webknjaz](https://redirect.github.com/sponsors/webknjaz) [πŸ‡ΊπŸ‡¦](https://stand-with-ukraine.pp.ua) **πŸ™ Special Thanks** to [@​woodruffw](https://redirect.github.com/woodruffw)[πŸ’°](https://redirect.github.com/sponsors/woodruffw) for releasing the license metadata support fix in Twine! **πŸ’¬ Discuss** [on Bluesky πŸ¦‹](https://bsky.app/profile/webknjaz.me/post/3lghhtwey522z), [on Mastodon 🐘](https://mastodon.social/@​webknjaz/113881243992941874) and [on GitHub][release discussion]. [![GH Sponsors badge]][GH Sponsors URL] [PEP 639 licensing metadata]: https://packaging.python.org/en/latest/specifications/core-metadata/#license-expression [release discussion]: https://redirect.github.com/pypa/gh-action-pypi-publish/discussions/330 [GH Sponsors badge]: https://img.shields.io/badge/%40webknjaz-transparent?logo=githubsponsors&logoColor=%23EA4AAA&label=Sponsor&color=2a313c [GH Sponsors URL]: https://redirect.github.com/sponsors/webknjaz
--- ### 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> --- .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 61ca6a8c..b582f606 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.12.3 + uses: pypa/gh-action-pypi-publish@v1.12.4 From 1395033b7245b58574edd715d3c8ac58e4026005 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Jan 2025 08:10:17 +0000 Subject: [PATCH 033/213] chore(deps): update pre-commit hook pycqa/isort to v6 (#471) --- .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 f6abcc5a..8f5a90d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: args: [--py39-plus] - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 6.0.0 hooks: - id: isort From aa2479d040d48605325ab7b72827159e54b1821d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Jan 2025 09:06:36 +0000 Subject: [PATCH 034/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v25 (#472) --- .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 8f5a90d3..4112efe7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -41,7 +41,7 @@ repos: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.10.0 + rev: 25.1.0 hooks: - id: black From 55d6064a8e1e6c690644d10cb70261651c78fefc Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 31 Jan 2025 12:10:57 +0000 Subject: [PATCH 035/213] docs: add links to api changelog (#473) Users must subscribe to our changelog, or they might not be notified about additions, deprecations and removals. --- README.md | 7 +++++++ hcloud/_client.py | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 4fe0b73f..40913a09 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,13 @@ Official Hetzner Cloud python library. The library's documentation is available at [hcloud-python.readthedocs.io](https://hcloud-python.readthedocs.io), the public API documentation is available at [docs.hetzner.cloud](https://docs.hetzner.cloud). +> [!IMPORTANT] +> Make sure to follow our API changelog available at +> [docs.hetzner.cloud/changelog](https://docs.hetzner.cloud/changelog) (or the RRS feed +> available at +> [docs.hetzner.cloud/changelog/feed.rss](https://docs.hetzner.cloud/changelog/feed.rss)) +> to be notified about additions, deprecations and removals. + ## Usage Install the `hcloud` library: diff --git a/hcloud/_client.py b/hcloud/_client.py index adbd9129..a466b744 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -84,6 +84,11 @@ class Client: The Hetzner Cloud API reference is available at https://docs.hetzner.cloud. + Make sure to follow our API changelog available at + https://docs.hetzner.cloud/changelog (or the RRS feed available at + https://docs.hetzner.cloud/changelog/feed.rss) to be notified about additions, + deprecations and removals. + **Retry mechanism** The :attr:`Client.request` method will retry failed requests that match certain criteria. The From eb43b2a43d7cb90c9dcb970bcf79bfacc8e93653 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 5 Feb 2025 10:13:15 +0100 Subject: [PATCH 036/213] chore(deps): update dependency mypy to >=1.15,<1.16 (#474) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fdc29213..5016f84b 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=6,<6.1", - "mypy>=1.14,<1.15", + "mypy>=1.15,<1.16", "types-python-dateutil", "types-requests", ], From 4a4587a1fc70b7f9b8f0dd30f05de9976e3efad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Mon, 10 Feb 2025 09:09:08 +0100 Subject: [PATCH 037/213] docs: fix typo in README (#475) `s/RRS/RSS/g` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 40913a09..a280f616 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The library's documentation is available at [hcloud-python.readthedocs.io](https > [!IMPORTANT] > Make sure to follow our API changelog available at -> [docs.hetzner.cloud/changelog](https://docs.hetzner.cloud/changelog) (or the RRS feed +> [docs.hetzner.cloud/changelog](https://docs.hetzner.cloud/changelog) (or the RSS feed > available at > [docs.hetzner.cloud/changelog/feed.rss](https://docs.hetzner.cloud/changelog/feed.rss)) > to be notified about additions, deprecations and removals. From 47d52c8a4dc0c4fbcf854c9716ef7fafb0d007e3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 16 Feb 2025 22:05:23 +0000 Subject: [PATCH 038/213] chore(deps): update pre-commit hook pycqa/flake8 to v7.1.2 (#476) 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 | |---|---|---|---| | [pycqa/flake8](https://redirect.github.com/pycqa/flake8) | repository | patch | `7.1.1` -> `7.1.2` | 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
pycqa/flake8 (pycqa/flake8) ### [`v7.1.2`](https://redirect.github.com/pycqa/flake8/compare/7.1.1...7.1.2) [Compare Source](https://redirect.github.com/pycqa/flake8/compare/7.1.1...7.1.2)
--- ### 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 4112efe7..1ac3dedd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,6 @@ repos: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.1.1 + rev: 7.1.2 hooks: - id: flake8 From 83290bfd55d2adb92f5b47052854cfe626012972 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Feb 2025 02:27:34 +0000 Subject: [PATCH 039/213] chore(deps): update pre-commit hook pycqa/isort to v6.0.1 (#478) 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 | |---|---|---|---| | [pycqa/isort](https://redirect.github.com/pycqa/isort) | repository | patch | `6.0.0` -> `6.0.1` | 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
pycqa/isort (pycqa/isort) ### [`v6.0.1`](https://redirect.github.com/PyCQA/isort/releases/tag/6.0.1) [Compare Source](https://redirect.github.com/pycqa/isort/compare/6.0.0...6.0.1) #### Changes - fix multi_line_output_modes docs ([#​2096](https://redirect.github.com/pycqa/isort/issues/2096)) [@​xinydev](https://redirect.github.com/xinydev) - Ruff rules PT for pytest ([#​2372](https://redirect.github.com/pycqa/isort/issues/2372)) [@​cclauss](https://redirect.github.com/cclauss) - Ruff rules B017, B028, and E203 ([#​2371](https://redirect.github.com/pycqa/isort/issues/2371)) [@​cclauss](https://redirect.github.com/cclauss) - Lint Python code with ruff ([#​2359](https://redirect.github.com/pycqa/isort/issues/2359)) [@​cclauss](https://redirect.github.com/cclauss) - Fix test_find_imports_in_file_error failing on Windows ([#​2369](https://redirect.github.com/pycqa/isort/issues/2369)) [@​kobarity](https://redirect.github.com/kobarity) - Move flake8 settings into pyproject.toml ([#​2360](https://redirect.github.com/pycqa/isort/issues/2360)) [@​cclauss](https://redirect.github.com/cclauss) - Upgrade to uv>=0.6.0 and enable-cache ([#​2364](https://redirect.github.com/pycqa/isort/issues/2364)) [@​cclauss](https://redirect.github.com/cclauss) - Apply some ruff rules ([#​2353](https://redirect.github.com/pycqa/isort/issues/2353)) [@​cclauss](https://redirect.github.com/cclauss) #### :beetle: Fixes - Add OSError handling in find_imports_in_file ([#​2331](https://redirect.github.com/pycqa/isort/issues/2331)) [@​kobarity](https://redirect.github.com/kobarity) - Link GH as changelog ([#​2354](https://redirect.github.com/pycqa/isort/issues/2354)) [@​staticdev](https://redirect.github.com/staticdev) #### :construction_worker: Continuous Integration - Remove Safety ([#​2373](https://redirect.github.com/pycqa/isort/issues/2373)) [@​staticdev](https://redirect.github.com/staticdev)
--- ### 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 1ac3dedd..8f3842f1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: args: [--py39-plus] - repo: https://github.com/pycqa/isort - rev: 6.0.0 + rev: 6.0.1 hooks: - id: isort From abbbc206918b7d7dff4e7a97153383e8299d950c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:42:22 +0100 Subject: [PATCH 040/213] chore(deps): update dependency coverage to >=7.7,<7.8 (#482) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5016f84b..d14c6a73 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.6,<7.7", + "coverage>=7.7,<7.8", "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=6,<6.1", From 993544ea250ff7b62f28c30e16a5023f796b8614 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 10:02:16 +0100 Subject: [PATCH 041/213] chore(deps): update dependency sphinx to >=8,<8.3 (#477) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d14c6a73..07a393a7 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ ], extras_require={ "docs": [ - "sphinx>=8,<8.2", + "sphinx>=8,<8.3", "sphinx-rtd-theme>=3,<3.1", "myst-parser>=4,<4.1", "watchdog>=6,<6.1", From 125a2ba493606f1e43de644913e9ab6e9f236626 Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Mon, 17 Mar 2025 08:25:17 -0700 Subject: [PATCH 042/213] refactor: explicitly reexport modules (#480) First step towards #479: * Explicitly re-export modules. --- hcloud/actions/__init__.py | 15 +++++++++++++-- hcloud/certificates/__init__.py | 14 ++++++++++++-- hcloud/core/__init__.py | 13 +++++++++++-- hcloud/datacenters/__init__.py | 12 ++++++++++-- hcloud/deprecation/__init__.py | 4 +++- hcloud/firewalls/__init__.py | 16 ++++++++++++++-- hcloud/floating_ips/__init__.py | 12 ++++++++++-- hcloud/helpers/__init__.py | 4 +++- hcloud/images/__init__.py | 12 ++++++++++-- hcloud/isos/__init__.py | 11 +++++++++-- hcloud/load_balancer_types/__init__.py | 11 +++++++++-- hcloud/load_balancers/__init__.py | 26 ++++++++++++++++++++++++-- hcloud/locations/__init__.py | 11 +++++++++-- hcloud/metrics/__init__.py | 7 ++++++- hcloud/networks/__init__.py | 14 ++++++++++++-- hcloud/placement_groups/__init__.py | 12 ++++++++++-- hcloud/primary_ips/__init__.py | 12 ++++++++++-- hcloud/server_types/__init__.py | 11 +++++++++-- hcloud/servers/__init__.py | 22 ++++++++++++++++++++-- hcloud/ssh_keys/__init__.py | 11 +++++++++-- hcloud/volumes/__init__.py | 12 ++++++++++-- pyproject.toml | 1 + 22 files changed, 224 insertions(+), 39 deletions(-) diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index ca93c89f..8a96e5de 100644 --- a/hcloud/actions/__init__.py +++ b/hcloud/actions/__init__.py @@ -1,14 +1,25 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( ActionsClient, ActionsPageResult, BoundAction, ResourceActionsClient, ) -from .domain import ( # noqa: F401 +from .domain import ( Action, ActionException, ActionFailedException, ActionTimeoutException, ) + +__all__ = [ + "Action", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", + "ActionsClient", + "ActionsPageResult", + "BoundAction", + "ResourceActionsClient", +] diff --git a/hcloud/certificates/__init__.py b/hcloud/certificates/__init__.py index 4e63df57..5b85358e 100644 --- a/hcloud/certificates/__init__.py +++ b/hcloud/certificates/__init__.py @@ -1,13 +1,23 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundCertificate, CertificatesClient, CertificatesPageResult, ) -from .domain import ( # noqa: F401 +from .domain import ( Certificate, CreateManagedCertificateResponse, ManagedCertificateError, ManagedCertificateStatus, ) + +__all__ = [ + "BoundCertificate", + "Certificate", + "CertificatesClient", + "CertificatesPageResult", + "CreateManagedCertificateResponse", + "ManagedCertificateError", + "ManagedCertificateStatus", +] diff --git a/hcloud/core/__init__.py b/hcloud/core/__init__.py index 4e17dac9..b553546a 100644 --- a/hcloud/core/__init__.py +++ b/hcloud/core/__init__.py @@ -1,4 +1,13 @@ from __future__ import annotations -from .client import BoundModelBase, ClientEntityBase # noqa: F401 -from .domain import BaseDomain, DomainIdentityMixin, Meta, Pagination # noqa: F401 +from .client import BoundModelBase, ClientEntityBase +from .domain import BaseDomain, DomainIdentityMixin, Meta, Pagination + +__all__ = [ + "BoundModelBase", + "ClientEntityBase", + "BaseDomain", + "DomainIdentityMixin", + "Meta", + "Pagination", +] diff --git a/hcloud/datacenters/__init__.py b/hcloud/datacenters/__init__.py index 559694c0..fdfbb189 100644 --- a/hcloud/datacenters/__init__.py +++ b/hcloud/datacenters/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundDatacenter, DatacentersClient, DatacentersPageResult, ) -from .domain import Datacenter, DatacenterServerTypes # noqa: F401 +from .domain import Datacenter, DatacenterServerTypes + +__all__ = [ + "BoundDatacenter", + "Datacenter", + "DatacenterServerTypes", + "DatacentersClient", + "DatacentersPageResult", +] diff --git a/hcloud/deprecation/__init__.py b/hcloud/deprecation/__init__.py index 315576b1..db30b0fb 100644 --- a/hcloud/deprecation/__init__.py +++ b/hcloud/deprecation/__init__.py @@ -1,3 +1,5 @@ from __future__ import annotations -from .domain import DeprecationInfo # noqa: F401 +from .domain import DeprecationInfo + +__all__ = ["DeprecationInfo"] diff --git a/hcloud/firewalls/__init__.py b/hcloud/firewalls/__init__.py index 5205d766..f81ae0df 100644 --- a/hcloud/firewalls/__init__.py +++ b/hcloud/firewalls/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -from .client import BoundFirewall, FirewallsClient, FirewallsPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundFirewall, FirewallsClient, FirewallsPageResult +from .domain import ( CreateFirewallResponse, Firewall, FirewallResource, @@ -9,3 +9,15 @@ FirewallResourceLabelSelector, FirewallRule, ) + +__all__ = [ + "BoundFirewall", + "CreateFirewallResponse", + "Firewall", + "FirewallResource", + "FirewallResourceAppliedToResources", + "FirewallResourceLabelSelector", + "FirewallRule", + "FirewallsClient", + "FirewallsPageResult", +] diff --git a/hcloud/floating_ips/__init__.py b/hcloud/floating_ips/__init__.py index 4e55bf5f..8c45c774 100644 --- a/hcloud/floating_ips/__init__.py +++ b/hcloud/floating_ips/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundFloatingIP, FloatingIPsClient, FloatingIPsPageResult, ) -from .domain import CreateFloatingIPResponse, FloatingIP # noqa: F401 +from .domain import CreateFloatingIPResponse, FloatingIP + +__all__ = [ + "BoundFloatingIP", + "CreateFloatingIPResponse", + "FloatingIP", + "FloatingIPsClient", + "FloatingIPsPageResult", +] diff --git a/hcloud/helpers/__init__.py b/hcloud/helpers/__init__.py index b6a4cd73..044703bf 100644 --- a/hcloud/helpers/__init__.py +++ b/hcloud/helpers/__init__.py @@ -1,3 +1,5 @@ from __future__ import annotations -from .labels import LabelValidator # noqa: F401 +from .labels import LabelValidator + +__all__ = ["LabelValidator"] diff --git a/hcloud/images/__init__.py b/hcloud/images/__init__.py index 78cb6868..071a92bc 100644 --- a/hcloud/images/__init__.py +++ b/hcloud/images/__init__.py @@ -1,4 +1,12 @@ from __future__ import annotations -from .client import BoundImage, ImagesClient, ImagesPageResult # noqa: F401 -from .domain import CreateImageResponse, Image # noqa: F401 +from .client import BoundImage, ImagesClient, ImagesPageResult +from .domain import CreateImageResponse, Image + +__all__ = [ + "BoundImage", + "CreateImageResponse", + "Image", + "ImagesClient", + "ImagesPageResult", +] diff --git a/hcloud/isos/__init__.py b/hcloud/isos/__init__.py index 0d5e38fa..91f137c3 100644 --- a/hcloud/isos/__init__.py +++ b/hcloud/isos/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundIso, IsosClient, IsosPageResult # noqa: F401 -from .domain import Iso # noqa: F401 +from .client import BoundIso, IsosClient, IsosPageResult +from .domain import Iso + +__all__ = [ + "BoundIso", + "Iso", + "IsosClient", + "IsosPageResult", +] diff --git a/hcloud/load_balancer_types/__init__.py b/hcloud/load_balancer_types/__init__.py index fa1dc33c..1b147bf7 100644 --- a/hcloud/load_balancer_types/__init__.py +++ b/hcloud/load_balancer_types/__init__.py @@ -1,8 +1,15 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundLoadBalancerType, LoadBalancerTypesClient, LoadBalancerTypesPageResult, ) -from .domain import LoadBalancerType # noqa: F401 +from .domain import LoadBalancerType + +__all__ = [ + "BoundLoadBalancerType", + "LoadBalancerType", + "LoadBalancerTypesClient", + "LoadBalancerTypesPageResult", +] diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 4bfd7994..33745163 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -1,11 +1,11 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundLoadBalancer, LoadBalancersClient, LoadBalancersPageResult, ) -from .domain import ( # noqa: F401 +from .domain import ( CreateLoadBalancerResponse, GetMetricsResponse, IPv4Address, @@ -23,3 +23,25 @@ PrivateNet, PublicNetwork, ) + +__all__ = [ + "BoundLoadBalancer", + "CreateLoadBalancerResponse", + "GetMetricsResponse", + "IPv4Address", + "IPv6Network", + "LoadBalancer", + "LoadBalancerAlgorithm", + "LoadBalancerHealtCheckHttp", + "LoadBalancerHealthCheck", + "LoadBalancerService", + "LoadBalancerServiceHttp", + "LoadBalancerTarget", + "LoadBalancerTargetHealthStatus", + "LoadBalancerTargetIP", + "LoadBalancerTargetLabelSelector", + "LoadBalancersClient", + "LoadBalancersPageResult", + "PrivateNet", + "PublicNetwork", +] diff --git a/hcloud/locations/__init__.py b/hcloud/locations/__init__.py index 1c23a517..f59c2644 100644 --- a/hcloud/locations/__init__.py +++ b/hcloud/locations/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundLocation, LocationsClient, LocationsPageResult # noqa: F401 -from .domain import Location # noqa: F401 +from .client import BoundLocation, LocationsClient, LocationsPageResult +from .domain import Location + +__all__ = [ + "BoundLocation", + "Location", + "LocationsClient", + "LocationsPageResult", +] diff --git a/hcloud/metrics/__init__.py b/hcloud/metrics/__init__.py index 65d393c8..49555d10 100644 --- a/hcloud/metrics/__init__.py +++ b/hcloud/metrics/__init__.py @@ -1,3 +1,8 @@ from __future__ import annotations -from .domain import Metrics, TimeSeries # noqa: F401 +from .domain import Metrics, TimeSeries + +__all__ = [ + "Metrics", + "TimeSeries", +] diff --git a/hcloud/networks/__init__.py b/hcloud/networks/__init__.py index 5bf4a88f..fc86564b 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -1,9 +1,19 @@ from __future__ import annotations -from .client import BoundNetwork, NetworksClient, NetworksPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundNetwork, NetworksClient, NetworksPageResult +from .domain import ( CreateNetworkResponse, Network, NetworkRoute, NetworkSubnet, ) + +__all__ = [ + "BoundNetwork", + "CreateNetworkResponse", + "Network", + "NetworkRoute", + "NetworkSubnet", + "NetworksClient", + "NetworksPageResult", +] diff --git a/hcloud/placement_groups/__init__.py b/hcloud/placement_groups/__init__.py index 9c25dd7f..ab82da5a 100644 --- a/hcloud/placement_groups/__init__.py +++ b/hcloud/placement_groups/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundPlacementGroup, PlacementGroupsClient, PlacementGroupsPageResult, ) -from .domain import CreatePlacementGroupResponse, PlacementGroup # noqa: F401 +from .domain import CreatePlacementGroupResponse, PlacementGroup + +__all__ = [ + "BoundPlacementGroup", + "CreatePlacementGroupResponse", + "PlacementGroup", + "PlacementGroupsClient", + "PlacementGroupsPageResult", +] diff --git a/hcloud/primary_ips/__init__.py b/hcloud/primary_ips/__init__.py index d079a23b..3dc5ed0a 100644 --- a/hcloud/primary_ips/__init__.py +++ b/hcloud/primary_ips/__init__.py @@ -1,4 +1,12 @@ from __future__ import annotations -from .client import BoundPrimaryIP, PrimaryIPsClient, PrimaryIPsPageResult # noqa: F401 -from .domain import CreatePrimaryIPResponse, PrimaryIP # noqa: F401 +from .client import BoundPrimaryIP, PrimaryIPsClient, PrimaryIPsPageResult +from .domain import CreatePrimaryIPResponse, PrimaryIP + +__all__ = [ + "BoundPrimaryIP", + "CreatePrimaryIPResponse", + "PrimaryIP", + "PrimaryIPsClient", + "PrimaryIPsPageResult", +] diff --git a/hcloud/server_types/__init__.py b/hcloud/server_types/__init__.py index 1e978d09..2cdab177 100644 --- a/hcloud/server_types/__init__.py +++ b/hcloud/server_types/__init__.py @@ -1,8 +1,15 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundServerType, ServerTypesClient, ServerTypesPageResult, ) -from .domain import ServerType # noqa: F401 +from .domain import ServerType + +__all__ = [ + "BoundServerType", + "ServerType", + "ServerTypesClient", + "ServerTypesPageResult", +] diff --git a/hcloud/servers/__init__.py b/hcloud/servers/__init__.py index 58c811e5..0b02a110 100644 --- a/hcloud/servers/__init__.py +++ b/hcloud/servers/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -from .client import BoundServer, ServersClient, ServersPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundServer, ServersClient, ServersPageResult +from .domain import ( CreateServerResponse, EnableRescueResponse, GetMetricsResponse, @@ -15,3 +15,21 @@ Server, ServerCreatePublicNetwork, ) + +__all__ = [ + "BoundServer", + "CreateServerResponse", + "EnableRescueResponse", + "GetMetricsResponse", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "PublicNetwork", + "PublicNetworkFirewall", + "RequestConsoleResponse", + "ResetPasswordResponse", + "Server", + "ServerCreatePublicNetwork", + "ServersClient", + "ServersPageResult", +] diff --git a/hcloud/ssh_keys/__init__.py b/hcloud/ssh_keys/__init__.py index 25155906..6aefb42e 100644 --- a/hcloud/ssh_keys/__init__.py +++ b/hcloud/ssh_keys/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundSSHKey, SSHKeysClient, SSHKeysPageResult # noqa: F401 -from .domain import SSHKey # noqa: F401 +from .client import BoundSSHKey, SSHKeysClient, SSHKeysPageResult +from .domain import SSHKey + +__all__ = [ + "BoundSSHKey", + "SSHKey", + "SSHKeysClient", + "SSHKeysPageResult", +] diff --git a/hcloud/volumes/__init__.py b/hcloud/volumes/__init__.py index dc8ccbb6..046df102 100644 --- a/hcloud/volumes/__init__.py +++ b/hcloud/volumes/__init__.py @@ -1,4 +1,12 @@ from __future__ import annotations -from .client import BoundVolume, VolumesClient, VolumesPageResult # noqa: F401 -from .domain import CreateVolumeResponse, Volume # noqa: F401 +from .client import BoundVolume, VolumesClient, VolumesPageResult +from .domain import CreateVolumeResponse, Volume + +__all__ = [ + "BoundVolume", + "CreateVolumeResponse", + "Volume", + "VolumesClient", + "VolumesPageResult", +] diff --git a/pyproject.toml b/pyproject.toml index d1bf2ad1..c1fd705e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ add_imports = ["from __future__ import annotations"] [tool.mypy] disallow_untyped_defs = true +implicit_reexport = false [tool.coverage.run] source = ["hcloud"] From 4d430f6d40a2cd90bef7d308695ded480fa1578a Mon Sep 17 00:00:00 2001 From: Stefano Rivera Date: Wed, 19 Mar 2025 06:02:04 -0700 Subject: [PATCH 043/213] feat: add equality checks to domains (#481) This allows easier introspection of API responses. Very useful for unit-testing behaviour of wrappers of hcloud. --- hcloud/core/client.py | 6 ++++++ hcloud/core/domain.py | 11 +++++++++++ tests/unit/core/test_client.py | 17 +++++++++++++++++ tests/unit/core/test_domain.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index d213daf0..0b86c837 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -96,3 +96,9 @@ def __repr__(self) -> str: # models, as they will generate a lot of API call trying to print all the fields # of the model. return object.__repr__(self) + + def __eq__(self, other: Any) -> bool: + """Compare a bound model object with another of the same type.""" + if not isinstance(other, self.__class__): + return NotImplemented + return self.data_model == other.data_model diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index d8485257..fa4b4bb9 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + class BaseDomain: __api_properties__: tuple @@ -16,6 +18,15 @@ def __repr__(self) -> str: kwargs = [f"{key}={getattr(self, key)!r}" for key in self.__api_properties__] # type: ignore[var-annotated] return f"{self.__class__.__qualname__}({', '.join(kwargs)})" + def __eq__(self, other: Any) -> bool: + """Compare a domain object with another of the same type.""" + if not isinstance(other, self.__class__): + return NotImplemented + for key in self.__api_properties__: + if getattr(self, key) != getattr(other, key): + return False + return True + class DomainIdentityMixin: diff --git a/tests/unit/core/test_client.py b/tests/unit/core/test_client.py index 3155eed6..4a6542eb 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -81,6 +81,23 @@ def test_get_non_exists_model_attribute_incomplete_model( client.get_by_id.assert_not_called() assert bound_model.complete is False + def test_equality(self, bound_model_class, client): + data = {"id": 1, "name": "name", "description": "my_description"} + bound_model_a = bound_model_class(client=client, data=data) + bound_model_b = bound_model_class(client=client, data=data) + + # Comparing a bound model with a base domain + assert bound_model_a == bound_model_a.data_model + + # Identical bound models + assert bound_model_a == bound_model_b + assert bound_model_a == bound_model_b.data_model + + # Differing bound models + bound_model_b.data_model.name = "changed_name" + assert bound_model_a != bound_model_b + assert bound_model_a != bound_model_b.data_model + class TestClientEntityBase: @pytest.fixture() diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index d5e588f1..c449df4e 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -156,3 +156,32 @@ def test_from_dict_ok(self, data_dict, expected_result): ) def test_repr_ok(self, data, expected): assert data.__repr__() == expected + + def test__eq__(self): + a1 = ActionDomain(id=1, name="action") + assert a1 == ActionDomain(id=1, name="action") + assert a1 != ActionDomain(id=2, name="action") + assert a1 != ActionDomain(id=1, name="something") + assert a1 != SomeOtherDomain(id=1, name="action") + + def test_nested__eq__(self): + child1 = ActionDomain(id=1, name="child") + d1 = SomeOtherDomain(id=1, name="parent", child=child1) + d2 = SomeOtherDomain(id=1, name="parent", child=child1) + + assert d1 == d2 + + d2.child = ActionDomain(id=2, name="child2") + + assert d1 != d2 + + def test_nested_list__eq__(self): + child1 = ActionDomain(id=1, name="child") + d1 = SomeOtherDomain(id=1, name="parent", child=[child1]) + d2 = SomeOtherDomain(id=1, name="parent", child=[child1]) + + assert d1 == d2 + + d2.child = [ActionDomain(id=2, name="child2")] + + assert d1 != d2 From 24db7dd654abba89bad4913b6e42bf3a473f5336 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Wed, 19 Mar 2025 14:02:30 +0100 Subject: [PATCH 044/213] ci: switch to releaser-pleaser (#483) Switch to [releaser-pleaser](https://github.com/apricote/releaser-pleaser) for planning new releases. This adds the following new features: - We can easily cut pre-releases, these are always useful if we want to allow some customers to test features before making an official release or to validate changes made to the release CI workflows. - We can add our own release notes to each release to better inform customers about the new features we added or about any deprecations. --- .github/release-please-config.json | 13 ------------- .github/release-please-manifest.json | 1 - .github/workflows/release-please.yml | 18 ------------------ .github/workflows/releaser-pleaser.yml | 25 +++++++++++++++++++++++++ .github/workflows/test.yml | 2 +- hcloud/_version.py | 2 +- setup.py | 2 +- 7 files changed, 28 insertions(+), 35 deletions(-) delete mode 100644 .github/release-please-config.json delete mode 100644 .github/release-please-manifest.json delete mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/releaser-pleaser.yml diff --git a/.github/release-please-config.json b/.github/release-please-config.json deleted file mode 100644 index 7386f9a6..00000000 --- a/.github/release-please-config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "bootstrap-sha": "155a565bd3dfbce3177554626ccea6ac35807786", - "include-component-in-tag": false, - "include-v-in-tag": true, - "packages": { - ".": { - "release-type": "python", - "package-name": "hcloud", - "extra-files": ["hcloud/_version.py"] - } - } -} diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json deleted file mode 100644 index 4b36daa1..00000000 --- a/.github/release-please-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{".":"2.3.0"} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index c0f43783..00000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Release-please - -on: - push: - branches: [main] - -jobs: - release-please: - # Do not run on forks. - if: github.repository == 'hetznercloud/hcloud-python' - - runs-on: ubuntu-latest - steps: - - uses: googleapis/release-please-action@v4 - with: - token: ${{ secrets.HCLOUD_BOT_TOKEN }} - config-file: .github/release-please-config.json - manifest-file: .github/release-please-manifest.json diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml new file mode 100644 index 00000000..5bdf97d0 --- /dev/null +++ b/.github/workflows/releaser-pleaser.yml @@ -0,0 +1,25 @@ +name: Releaser-pleaser + +on: + push: + branches: [main] + pull_request_target: + types: + - edited + - labeled + - unlabeled + +jobs: + releaser-pleaser: + # Do not run on forks. + if: github.repository == 'hetznercloud/hcloud-python' + + runs-on: ubuntu-latest + steps: + - name: releaser-pleaser + uses: apricote/releaser-pleaser@v0.5.0 + with: + token: ${{ secrets.HCLOUD_BOT_TOKEN }} + extra-files: | + setup.py + hcloud/_version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5949e58f..fbc96f3d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,7 +31,7 @@ jobs: - name: Upload coverage reports to Codecov if: > !startsWith(github.head_ref, 'renovate/') && - !startsWith(github.head_ref, 'release-please--') + !startsWith(github.head_ref, 'releaser-pleaser--') uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/hcloud/_version.py b/hcloud/_version.py index bb279990..f20936d0 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.3.0" # x-release-please-version +__version__ = "2.3.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 07a393a7..1df2e4d5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.3.0", + version="2.3.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From b4bbb314c9d0dd3beff9f1a592a2d016f88393f9 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:24:01 +0100 Subject: [PATCH 045/213] chore(main): release v2.4.0 (#484) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - drop support for python 3.8 (#458) - add equality checks to domains (#481) ### Bug Fixes - server public ipv4 and ipv6 properties are nullable (#455) ---

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: releaser-pleaser <> --- CHANGELOG.md | 11 +++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416f053..1d3db0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [v2.4.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.4.0) + +### Features + +- drop support for python 3.8 (#458) +- add equality checks to domains (#481) + +### Bug Fixes + +- server public ipv4 and ipv6 properties are nullable (#455) + ## [2.3.0](https://github.com/hetznercloud/hcloud-python/compare/v2.2.1...v2.3.0) (2024-10-09) diff --git a/hcloud/_version.py b/hcloud/_version.py index f20936d0..591adbfb 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.3.0" # x-releaser-pleaser-version +__version__ = "2.4.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 1df2e4d5..0cde92be 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.3.0", # x-releaser-pleaser-version + version="2.4.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From ee7642b23744a446d22b176becd7a4204081ff38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 30 Mar 2025 01:39:02 +0000 Subject: [PATCH 046/213] chore(deps): update pre-commit hook pycqa/flake8 to v7.2.0 (#485) 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 | |---|---|---|---| | [pycqa/flake8](https://redirect.github.com/pycqa/flake8) | repository | minor | `7.1.2` -> `7.2.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
pycqa/flake8 (pycqa/flake8) ### [`v7.2.0`](https://redirect.github.com/pycqa/flake8/compare/7.1.2...7.2.0) [Compare Source](https://redirect.github.com/pycqa/flake8/compare/7.1.2...7.2.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 8f3842f1..3c44d12f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,6 @@ repos: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.1.2 + rev: 7.2.0 hooks: - id: flake8 From 799c3a476ac23de41d6fd8440fad482c73ed5fcd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:15:56 +0200 Subject: [PATCH 047/213] chore(deps): update dependency pytest-cov to >=6,<6.2 (#487) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0cde92be..9a0c9e0a 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "coverage>=7.7,<7.8", "pylint>=3,<3.4", "pytest>=8,<8.4", - "pytest-cov>=6,<6.1", + "pytest-cov>=6,<6.2", "mypy>=1.15,<1.16", "types-python-dateutil", "types-requests", From ae096bafa06044fb493b0914143678144f66cd87 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 8 Apr 2025 15:16:08 +0200 Subject: [PATCH 048/213] chore(deps): update dependency coverage to >=7.8,<7.9 (#486) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 9a0c9e0a..958b59e1 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.7,<7.8", + "coverage>=7.8,<7.9", "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=6,<6.2", From d16acbe4224846b50fe35da78cc0ccc22ac773e7 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 25 Apr 2025 11:37:03 +0200 Subject: [PATCH 049/213] feat: improve exception messages (#488) Follow the action error message pattern that we used in hcloud-go. --- hcloud/_exceptions.py | 8 +++-- hcloud/actions/domain.py | 15 ++++++++- tests/unit/test_exceptions.py | 62 +++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_exceptions.py diff --git a/hcloud/_exceptions.py b/hcloud/_exceptions.py index c884a9a9..e33105a3 100644 --- a/hcloud/_exceptions.py +++ b/hcloud/_exceptions.py @@ -4,11 +4,15 @@ class HCloudException(Exception): - """There was an error while using the hcloud library""" + """There was an error while using the hcloud library. + + All exceptions in the hcloud library inherit from this exception. It may be used as + catch-all exception. + """ class APIException(HCloudException): - """There was an error while performing an API Request""" + """There was an error while performing an API Request.""" def __init__( self, diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index 71f3f3a3..aa7f2fd9 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -71,9 +71,22 @@ class ActionException(HCloudException): def __init__(self, action: Action | BoundAction): assert self.__doc__ is not None message = self.__doc__ - if action.error is not None and "message" in action.error: + + extras = [] + if ( + action.error is not None + and "code" in action.error + and "message" in action.error + ): message += f": {action.error['message']}" + extras.append(action.error["code"]) + else: + extras.append(action.command) + + extras.append(str(action.id)) + message += f" ({', '.join(extras)})" + super().__init__(message) self.message = message self.action = action diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..c4546ec8 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from hcloud import ( + APIException, + HCloudException, +) +from hcloud.actions import Action, ActionFailedException, ActionTimeoutException + +running_action = Action( + id=12345, + command="action_command", + status=Action.STATUS_RUNNING, +) + +failed_action = Action( + id=12345, + command="action_command", + status=Action.STATUS_ERROR, + error={"code": "action_failed", "message": "Action failed"}, +) + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + ( + # Should never be raised by itself + HCloudException(), + "", + ), + ( + # Should never be raised by itself + HCloudException("A test error"), + "A test error", + ), + ( + APIException(code="conflict", message="API error message", details=None), + "API error message (conflict)", + ), + ( + APIException( + code="conflict", + message="API error message", + details=None, + correlation_id="fddea8fabd02fb21", + ), + "API error message (conflict, fddea8fabd02fb21)", + ), + ( + ActionFailedException(failed_action), + "The pending action failed: Action failed (action_failed, 12345)", + ), + ( + ActionTimeoutException(running_action), + "The pending action timed out (action_command, 12345)", + ), + ], +) +def test_exceptions(exception, expected): + assert str(exception) == expected From 6a4d9823cd3cf83fb09d2d60cae9d79c12d82d61 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 25 Apr 2025 11:53:23 +0200 Subject: [PATCH 050/213] chore(main): release v2.5.0 (#489) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - improve exception messages (#488) ---

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: releaser-pleaser <> --- 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 1d3db0d8..caea3fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.5.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.0) + +### Features + +- improve exception messages (#488) + ## [v2.4.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.4.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 591adbfb..3f2e8eb3 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.4.0" # x-releaser-pleaser-version +__version__ = "2.5.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 958b59e1..85f4e114 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.4.0", # x-releaser-pleaser-version + version="2.5.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 34edc3a309e050f4b89d70457507790dfae9a0d3 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 30 Apr 2025 08:57:51 +0200 Subject: [PATCH 051/213] fix: missing slots and api_properties for FirewallResourceLabelSelector (#492) Fixes #491 --- hcloud/firewalls/domain.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py index e0787ec8..44825f37 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -198,6 +198,9 @@ class FirewallResourceLabelSelector(BaseDomain): :param selector: str Target label selector """ + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + def __init__(self, selector: str | None = None): self.selector = selector From f7a5e187b66d6113e7c7fe7f628a316924af520d Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:59:54 +0200 Subject: [PATCH 052/213] chore(main): release v2.5.1 (#493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - missing slots and api_properties for FirewallResourceLabelSelector (#492) ---

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: releaser-pleaser <> --- 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 caea3fdb..a46cc826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.5.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.1) + +### Bug Fixes + +- missing slots and api_properties for FirewallResourceLabelSelector (#492) + ## [v2.5.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 3f2e8eb3..a077b8e8 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.5.0" # x-releaser-pleaser-version +__version__ = "2.5.1" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 85f4e114..52a4ac6b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.5.0", # x-releaser-pleaser-version + version="2.5.1", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From d39af0e1e1cf6df03cde7df7da627d9db7f87be9 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 30 Apr 2025 16:12:34 +0200 Subject: [PATCH 053/213] chore: generate xml coverage instead of html (#494) User should generate a html report and open it themselves. --- Makefile | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Makefile b/Makefile index b63a6ff2..9a063b01 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,7 @@ test: venv coverage: venv venv/bin/coverage run -m pytest -v venv/bin/coverage report --show-missing - venv/bin/coverage html - xdg-open htmlcov/index.html + venv/bin/coverage xml export SPHINXBUILD=../venv/bin/sphinx-build docs: venv From dbaa3effbf18a7b6a7608bb2f3b648f1ddfd0cd6 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 7 May 2025 13:15:39 +0200 Subject: [PATCH 054/213] fix: listing page result always provide meta (#496) The API specification now describe all meta/pagination fields as required, this PR updates the library to reflect that. Related #251 --- hcloud/actions/client.py | 2 +- hcloud/certificates/client.py | 2 +- hcloud/core/domain.py | 5 ++--- hcloud/datacenters/client.py | 2 +- hcloud/firewalls/client.py | 2 +- hcloud/floating_ips/client.py | 2 +- hcloud/images/client.py | 2 +- hcloud/isos/client.py | 2 +- hcloud/load_balancer_types/client.py | 2 +- hcloud/load_balancers/client.py | 2 +- hcloud/locations/client.py | 2 +- hcloud/networks/client.py | 2 +- hcloud/placement_groups/client.py | 2 +- hcloud/primary_ips/client.py | 2 +- hcloud/server_types/client.py | 2 +- hcloud/servers/client.py | 2 +- hcloud/ssh_keys/client.py | 2 +- hcloud/volumes/client.py | 2 +- tests/unit/actions/test_client.py | 4 ++-- tests/unit/certificates/test_client.py | 4 ++-- tests/unit/core/test_domain.py | 2 +- tests/unit/datacenters/test_client.py | 2 +- tests/unit/firewalls/test_client.py | 8 ++++---- tests/unit/floating_ips/test_client.py | 4 ++-- tests/unit/images/test_client.py | 8 ++++---- tests/unit/isos/test_client.py | 2 +- tests/unit/load_balancer_types/test_client.py | 2 +- tests/unit/load_balancers/test_client.py | 6 +++--- tests/unit/locations/test_client.py | 2 +- tests/unit/networks/test_client.py | 4 ++-- tests/unit/placement_groups/test_client.py | 2 +- tests/unit/primary_ips/test_client.py | 2 +- tests/unit/server_types/test_client.py | 2 +- tests/unit/servers/test_client.py | 8 ++++---- tests/unit/volumes/test_client.py | 4 ++-- 35 files changed, 52 insertions(+), 53 deletions(-) diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index a3d6b033..7ec192c8 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -47,7 +47,7 @@ def wait_until_finished(self, max_retries: int | None = None) -> None: class ActionsPageResult(NamedTuple): actions: list[BoundAction] - meta: Meta | None + meta: Meta class ResourceActionsClient(ClientEntityBase): diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index a5fe1d77..cb1aef58 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -100,7 +100,7 @@ def retry_issuance(self) -> BoundAction: class CertificatesPageResult(NamedTuple): certificates: list[BoundCertificate] - meta: Meta | None + meta: Meta class CertificatesClient(ClientEntityBase): diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index fa4b4bb9..f3050301 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -99,13 +99,12 @@ def __init__(self, pagination: Pagination | None = None): self.pagination = pagination @classmethod - def parse_meta(cls, response: dict) -> Meta | None: + def parse_meta(cls, response: dict) -> Meta: """ If present, extract the meta details from the response and return a meta object. """ - meta = None + meta = cls() if response and "meta" in response: - meta = cls() try: meta.pagination = Pagination(**response["meta"]["pagination"]) except KeyError: diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 1be1e126..935a7c77 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -52,7 +52,7 @@ def __init__(self, client: DatacentersClient, data: dict): class DatacentersPageResult(NamedTuple): datacenters: list[BoundDatacenter] - meta: Meta | None + meta: Meta class DatacentersClient(ClientEntityBase): diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index fbcd1008..ae365a4b 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -180,7 +180,7 @@ def remove_from_resources( class FirewallsPageResult(NamedTuple): firewalls: list[BoundFirewall] - meta: Meta | None + meta: Meta class FirewallsClient(ClientEntityBase): diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index a7669147..9306ac2f 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -136,7 +136,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: class FloatingIPsPageResult(NamedTuple): floating_ips: list[BoundFloatingIP] - meta: Meta | None + meta: Meta class FloatingIPsClient(ClientEntityBase): diff --git a/hcloud/images/client.py b/hcloud/images/client.py index a7d41e01..36c59e06 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -109,7 +109,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: class ImagesPageResult(NamedTuple): images: list[BoundImage] - meta: Meta | None + meta: Meta class ImagesClient(ClientEntityBase): diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index fa7b1a8b..31620d8f 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -17,7 +17,7 @@ class BoundIso(BoundModelBase, Iso): class IsosPageResult(NamedTuple): isos: list[BoundIso] - meta: Meta | None + meta: Meta class IsosClient(ClientEntityBase): diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 9a83dc70..5f332c6e 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -17,7 +17,7 @@ class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): class LoadBalancerTypesPageResult(NamedTuple): load_balancer_types: list[BoundLoadBalancerType] - meta: Meta | None + meta: Meta class LoadBalancerTypesClient(ClientEntityBase): diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index e0635eec..aeeeb879 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -366,7 +366,7 @@ def change_type( class LoadBalancersPageResult(NamedTuple): load_balancers: list[BoundLoadBalancer] - meta: Meta | None + meta: Meta class LoadBalancersClient(ClientEntityBase): diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 047ad9d4..411a184d 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -17,7 +17,7 @@ class BoundLocation(BoundModelBase, Location): class LocationsPageResult(NamedTuple): locations: list[BoundLocation] - meta: Meta | None + meta: Meta class LocationsClient(ClientEntityBase): diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index d819d580..c446ae6b 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -163,7 +163,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: class NetworksPageResult(NamedTuple): networks: list[BoundNetwork] - meta: Meta | None + meta: Meta class NetworksClient(ClientEntityBase): diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index fcfd86ae..0c53724f 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -40,7 +40,7 @@ def delete(self) -> bool: class PlacementGroupsPageResult(NamedTuple): placement_groups: list[BoundPlacementGroup] - meta: Meta | None + meta: Meta class PlacementGroupsClient(ClientEntityBase): diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 41ef3bfb..818737c5 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -94,7 +94,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: class PrimaryIPsPageResult(NamedTuple): primary_ips: list[BoundPrimaryIP] - meta: Meta | None + meta: Meta class PrimaryIPsClient(ClientEntityBase): diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 31f56a20..6901d995 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -17,7 +17,7 @@ class BoundServerType(BoundModelBase, ServerType): class ServerTypesPageResult(NamedTuple): server_types: list[BoundServerType] - meta: Meta | None + meta: Meta class ServerTypesClient(ClientEntityBase): diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index ca741ebf..d72bf803 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -479,7 +479,7 @@ def remove_from_placement_group(self) -> BoundAction: class ServersPageResult(NamedTuple): servers: list[BoundServer] - meta: Meta | None + meta: Meta class ServersClient(ClientEntityBase): diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index 69c1683d..12064ea9 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -38,7 +38,7 @@ def delete(self) -> bool: class SSHKeysPageResult(NamedTuple): ssh_keys: list[BoundSSHKey] - meta: Meta | None + meta: Meta class SSHKeysClient(ClientEntityBase): diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index a4709748..371a8bd1 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -132,7 +132,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: class VolumesPageResult(NamedTuple): volumes: list[BoundVolume] - meta: Meta | None + meta: Meta class VolumesClient(ClientEntityBase): diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index a8091b72..f9943087 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -89,7 +89,7 @@ def test_get_list(self, actions_client, generic_action_list, params): url="/resource/actions", method="GET", params=params ) - assert result.meta is None + assert result.meta is not None actions = result.actions assert len(actions) == 2 @@ -157,7 +157,7 @@ def test_get_list(self, actions_client, generic_action_list, params): url="/actions", method="GET", params=params ) - assert result.meta is None + assert result.meta is not None actions = result.actions assert len(actions) == 2 diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 0857d0c9..a4e5ad2e 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -29,7 +29,7 @@ def test_get_actions_list( ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -315,7 +315,7 @@ def test_actions_get_list(self, certificates_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index c449df4e..484799f6 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -10,7 +10,7 @@ class TestMeta: @pytest.mark.parametrize("json_content", [None, "", {}]) def test_parse_meta_empty_json(self, json_content): result = Meta.parse_meta(json_content) - assert result is None + assert result is not None def test_parse_meta_json_no_paginaton(self): json_content = {"meta": {}} diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 369566b4..3da55e37 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -82,7 +82,7 @@ def test_get_list(self, datacenters_client, two_datacenters_response, params): ) datacenters = result.datacenters - assert result.meta is None + assert result.meta is not None assert len(datacenters) == 2 diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index 4e8cea94..1226c61f 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -84,7 +84,7 @@ def test_get_actions_list( ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -231,7 +231,7 @@ def test_get_list(self, firewalls_client, two_firewalls_response, params): ) firewalls = result.firewalls - assert result.meta is None + assert result.meta is not None assert len(firewalls) == 2 @@ -305,7 +305,7 @@ def test_get_actions_list(self, firewalls_client, firewall, response_get_actions ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -489,7 +489,7 @@ def test_actions_get_list(self, firewalls_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index a56f0829..1f1160c6 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -165,7 +165,7 @@ def test_get_list(self, floating_ips_client, two_floating_ips_response, params): ) bound_floating_ips = result.floating_ips - assert result.meta is None + assert result.meta is not None assert len(bound_floating_ips) == 2 @@ -419,7 +419,7 @@ def test_actions_get_list(self, floating_ips_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 8e1dc490..744b13bb 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -59,7 +59,7 @@ def test_get_actions_list( ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -162,7 +162,7 @@ def test_get_list(self, images_client, two_images_response, params): ) images = result.images - assert result.meta is None + assert result.meta is not None assert len(images) == 2 @@ -254,7 +254,7 @@ def test_get_actions_list(self, images_client, image, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -337,7 +337,7 @@ def test_actions_get_list(self, images_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index fc64ab47..7e71f8dc 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -63,7 +63,7 @@ def test_get_list(self, isos_client, two_isos_response, params): ) isos = result.isos - assert result.meta is None + assert result.meta is not None assert len(isos) == 2 diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index 23d2d14f..f0a4c12e 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -39,7 +39,7 @@ def test_get_list( ) load_balancer_types = result.load_balancer_types - assert result.meta is None + assert result.meta is not None assert len(load_balancer_types) == 2 diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 3e9f281a..02ce1e50 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -46,7 +46,7 @@ def test_get_actions_list( ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -421,7 +421,7 @@ def test_get_list( ) bound_load_balancers = result.load_balancers - assert result.meta is None + assert result.meta is not None assert len(bound_load_balancers) == 2 @@ -569,7 +569,7 @@ def test_actions_get_list(self, load_balancers_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index 9356707a..c288230a 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -34,7 +34,7 @@ def test_get_list(self, locations_client, two_locations_response, params): ) locations = result.locations - assert result.meta is None + assert result.meta is not None assert len(locations) == 2 diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index ff4dd233..4e986414 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -214,7 +214,7 @@ def test_get_list(self, networks_client, two_networks_response, params): ) bound_networks = result.networks - assert result.meta is None + assert result.meta is not None assert len(bound_networks) == 2 @@ -613,7 +613,7 @@ def test_actions_get_list(self, networks_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 6ba5db5c..27410cf9 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -108,7 +108,7 @@ def test_get_list( ) placement_groups = result.placement_groups - assert result.meta is None + assert result.meta is not None assert len(placement_groups) == len( two_placement_groups_response["placement_groups"] diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 9db08fe4..65a19be9 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -321,7 +321,7 @@ def test_actions_get_list(self, primary_ips_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 54ab3a9b..91d94e28 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -64,7 +64,7 @@ def test_get_list(self, server_types_client, two_server_types_response, params): ) server_types = result.server_types - assert result.meta is None + assert result.meta is not None assert len(server_types) == 2 diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index b338979b..8f103df7 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -143,7 +143,7 @@ def test_get_actions_list( ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -558,7 +558,7 @@ def test_get_list(self, servers_client, response_simple_servers, params): ) bound_servers = result.servers - assert result.meta is None + assert result.meta is not None assert len(bound_servers) == 2 @@ -853,7 +853,7 @@ def test_get_actions_list(self, servers_client, server, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) @@ -1288,7 +1288,7 @@ def test_actions_get_list(self, servers_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index bb8bed00..1133a417 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -159,7 +159,7 @@ def test_get_list(self, volumes_client, two_volumes_response, params): ) bound_volumes = result.volumes - assert result.meta is None + assert result.meta is not None assert len(bound_volumes) == 2 @@ -412,7 +412,7 @@ def test_actions_get_list(self, volumes_client, response_get_actions): ) actions = result.actions - assert result.meta is None + assert result.meta is not None assert len(actions) == 1 assert isinstance(actions[0], BoundAction) From 8b5ae603f554c274735dbb51d1f9d8607f827861 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 00:30:09 +0000 Subject: [PATCH 055/213] chore(deps): update apricote/releaser-pleaser action to v0.5.1 (#498) 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 | |---|---|---|---| | [apricote/releaser-pleaser](https://redirect.github.com/apricote/releaser-pleaser) | action | patch | `v0.5.0` -> `v0.5.1` | --- ### Release Notes
apricote/releaser-pleaser (apricote/releaser-pleaser) ### [`v0.5.1`](https://redirect.github.com/apricote/releaser-pleaser/blob/HEAD/CHANGELOG.md#v051) [Compare Source](https://redirect.github.com/apricote/releaser-pleaser/compare/v0.5.0...v0.5.1) ##### Bug Fixes - invalid version for subsequent pre-releases ([#​174](https://redirect.github.com/apricote/releaser-pleaser/issues/174))
--- ### 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> --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 5bdf97d0..317a08c0 100644 --- a/.github/workflows/releaser-pleaser.yml +++ b/.github/workflows/releaser-pleaser.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: releaser-pleaser - uses: apricote/releaser-pleaser@v0.5.0 + uses: apricote/releaser-pleaser@v0.5.1 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From c8c1ed3fee80a4e035fd90e621fe039003521888 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 23 May 2025 11:16:57 +0200 Subject: [PATCH 056/213] chore(main): release v2.5.2 (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - listing page result always provide meta (#496) ---

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: releaser-pleaser <> --- 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 a46cc826..067145aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.5.2](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.2) + +### Bug Fixes + +- listing page result always provide meta (#496) + ## [v2.5.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.1) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index a077b8e8..e93dacf8 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.5.1" # x-releaser-pleaser-version +__version__ = "2.5.2" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 52a4ac6b..e3c10188 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.5.1", # x-releaser-pleaser-version + version="2.5.2", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 198f5493dd880c66974c9bd2d6b208280b19cb8d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 23 May 2025 23:22:37 +0000 Subject: [PATCH 057/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.20.0 (#499) 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 | |---|---|---|---| | [asottile/pyupgrade](https://redirect.github.com/asottile/pyupgrade) | repository | minor | `v3.19.1` -> `v3.20.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
asottile/pyupgrade (asottile/pyupgrade) ### [`v3.20.0`](https://redirect.github.com/asottile/pyupgrade/compare/v3.19.1...v3.20.0) [Compare Source](https://redirect.github.com/asottile/pyupgrade/compare/v3.19.1...v3.20.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 3c44d12f..23d234b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.20.0 hooks: - id: pyupgrade args: [--py39-plus] From 0f0cbc0b683c15f3915fbb6557078c08d5ca55fb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:55:25 +0200 Subject: [PATCH 058/213] chore(deps): update dependency mypy to >=1.16,<1.17 (#500) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [mypy](https://redirect.github.com/python/mypy) ([changelog](https://mypy.readthedocs.io/en/latest/changelog.html)) | `>=1.15,<1.16` -> `>=1.16,<1.17` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/mypy/1.16.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/mypy/1.16.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/mypy/1.15.0/1.16.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/mypy/1.15.0/1.16.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
python/mypy (mypy) ### [`v1.16.0`](https://redirect.github.com/python/mypy/compare/v1.15.0...v1.16.0) [Compare Source](https://redirect.github.com/python/mypy/compare/v1.15.0...v1.16.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e3c10188..1895bdde 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.4", "pytest-cov>=6,<6.2", - "mypy>=1.15,<1.16", + "mypy>=1.16,<1.17", "types-python-dateutil", "types-requests", ], From c06cf18e8120ea92957b17d613e220fdb11e464d Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 20:57:40 +0200 Subject: [PATCH 059/213] chore(deps): update dependency pytest to >=8,<8.5 (#502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [pytest](https://redirect.github.com/pytest-dev/pytest) ([changelog](https://docs.pytest.org/en/stable/changelog.html)) | `>=8,<8.4` -> `>=8,<8.5` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest/8.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/pytest/8.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/pytest/8.3.5/8.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest/8.3.5/8.4.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
pytest-dev/pytest (pytest) ### [`v8.4.0`](https://redirect.github.com/pytest-dev/pytest/releases/tag/8.4.0) [Compare Source](https://redirect.github.com/pytest-dev/pytest/compare/8.3.5...8.4.0) ### pytest 8.4.0 (2025-06-02) #### Removals and backward incompatible breaking changes - [#​11372](https://redirect.github.com/pytest-dev/pytest/issues/11372): Async tests will now fail, instead of warning+skipping, if you don't have any suitable plugin installed. - [#​12346](https://redirect.github.com/pytest-dev/pytest/issues/12346): Tests will now fail, instead of raising a warning, if they return any value other than None. - [#​12874](https://redirect.github.com/pytest-dev/pytest/issues/12874): We dropped support for Python 3.8 following its end of life (2024-10-07). - [#​12960](https://redirect.github.com/pytest-dev/pytest/issues/12960): Test functions containing a yield now cause an explicit error. They have not been run since pytest 4.0, and were previously marked as an expected failure and deprecation warning. See `the docs `{.interpreted-text role="ref"} for more information. #### Deprecations (removal in next major release) - [#​10839](https://redirect.github.com/pytest-dev/pytest/issues/10839): Requesting an asynchronous fixture without a \[pytest_fixture_setup]{.title-ref} hook that resolves it will now give a DeprecationWarning. This most commonly happens if a sync test requests an async fixture. This should have no effect on a majority of users with async tests or fixtures using async pytest plugins, but may affect non-standard hook setups or `autouse=True`. For guidance on how to work around this warning see `sync-test-async-fixture`{.interpreted-text role="ref"}. #### New features - [#​11538](https://redirect.github.com/pytest-dev/pytest/issues/11538): Added `pytest.RaisesGroup`{.interpreted-text role="class"} as an equivalent to `pytest.raises`{.interpreted-text role="func"} for expecting `ExceptionGroup`{.interpreted-text role="exc"}. Also adds `pytest.RaisesExc`{.interpreted-text role="class"} which is now the logic behind `pytest.raises`{.interpreted-text role="func"} and used as parameter to `pytest.RaisesGroup`{.interpreted-text role="class"}. `RaisesGroup` includes the ability to specify multiple different expected exceptions, the structure of nested exception groups, and flags for emulating `except* `{.interpreted-text role="ref"}. See `assert-matching-exception-groups`{.interpreted-text role="ref"} and docstrings for more information. - [#​12081](https://redirect.github.com/pytest-dev/pytest/issues/12081): Added `capteesys`{.interpreted-text role="fixture"} to capture AND pass output to next handler set by `--capture=`. - [#​12504](https://redirect.github.com/pytest-dev/pytest/issues/12504): `pytest.mark.xfail`{.interpreted-text role="func"} now accepts `pytest.RaisesGroup`{.interpreted-text role="class"} for the `raises` parameter when you expect an exception group. You can also pass a `pytest.RaisesExc`{.interpreted-text role="class"} if you e.g. want to make use of the `check` parameter. - [#​12713](https://redirect.github.com/pytest-dev/pytest/issues/12713): New \[--force-short-summary]{.title-ref} option to force condensed summary output regardless of verbosity level. This lets users still see condensed summary output of failures for quick reference in log files from job outputs, being especially useful if non-condensed output is very verbose. - [#​12749](https://redirect.github.com/pytest-dev/pytest/issues/12749): pytest traditionally collects classes/functions in the test module namespace even if they are imported from another file. For example: ```python ``` ### contents of src/domain.py class Testament: ... ### contents of tests/test_testament.py from domain import Testament def test_testament(): ... ``` In this scenario with the default options, pytest will collect the class [Testament]{.title-ref} from [tests/test_testament.py]{.title-ref} because it starts with [Test]{.title-ref}, even though in this case it is a production class being imported in the test module namespace. This behavior can now be prevented by setting the new `collect_imported_tests`{.interpreted-text role="confval"} configuration option to `false`, which will make pytest collect classes/functions from test files **only** if they are defined in that file. \-- by `FreerGit`{.interpreted-text role="user"} - [#​12765](https://redirect.github.com/pytest-dev/pytest/issues/12765): Thresholds to trigger snippet truncation can now be set with `truncation_limit_lines`{.interpreted-text role="confval"} and `truncation_limit_chars`{.interpreted-text role="confval"}. See `truncation-params`{.interpreted-text role="ref"} for more information. - [#​13125](https://redirect.github.com/pytest-dev/pytest/issues/13125): `console_output_style`{.interpreted-text role="confval"} now supports `times` to show execution time of each test. - [#​13192](https://redirect.github.com/pytest-dev/pytest/issues/13192): `pytest.raises`{.interpreted-text role="func"} will now raise a warning when passing an empty string to `match`, as this will match against any value. Use `match="^$"` if you want to check that an exception has no message. - [#​13192](https://redirect.github.com/pytest-dev/pytest/issues/13192): `pytest.raises`{.interpreted-text role="func"} will now print a helpful string diff if matching fails and the match parameter has `^` and `$` and is otherwise escaped. - [#​13192](https://redirect.github.com/pytest-dev/pytest/issues/13192): You can now pass `with pytest.raises(check=fn): `{.interpreted-text role="func"}, where `fn` is a function which takes a raised exception and returns a boolean. The `raises` fails if no exception was raised (as usual), passes if an exception is raised and `fn` returns `True` (as well as `match` and the type matching, if specified, which are checked before), and propagates the exception if `fn` returns `False` (which likely also fails the test). - [#​13228](https://redirect.github.com/pytest-dev/pytest/issues/13228): `hidden-param`{.interpreted-text role="ref"} can now be used in `id` of `pytest.param`{.interpreted-text role="func"} or in `ids` of `Metafunc.parametrize `{.interpreted-text role="py:func"}. It hides the parameter set from the test name. - [#​13253](https://redirect.github.com/pytest-dev/pytest/issues/13253): New flag: `--disable-plugin-autoload `{.interpreted-text role="ref"} which works as an alternative to `PYTEST_DISABLE_PLUGIN_AUTOLOAD`{.interpreted-text role="envvar"} when setting environment variables is inconvenient; and allows setting it in config files with `addopts`{.interpreted-text role="confval"}. #### Improvements in existing functionality - [#​10224](https://redirect.github.com/pytest-dev/pytest/issues/10224): pytest's `short` and `long` traceback styles (`how-to-modifying-python-tb-printing`{.interpreted-text role="ref"}) now have partial `657`{.interpreted-text role="pep"} support and will show specific code segments in the traceback. ```pytest ================================= FAILURES ================================= _______________________ test_gets_correct_tracebacks _______________________ test_tracebacks.py:12: in test_gets_correct_tracebacks assert manhattan_distance(p1, p2) == 1 ^^^^^^^^^^^^^^^^^^^^^^^^^^ test_tracebacks.py:6: in manhattan_distance return abs(point_1.x - point_2.x) + abs(point_1.y - point_2.y) ^^^^^^^^^ E AttributeError: 'NoneType' object has no attribute 'x' ``` \-- by `ammaraskar`{.interpreted-text role="user"} - [#​11118](https://redirect.github.com/pytest-dev/pytest/issues/11118): Now `pythonpath`{.interpreted-text role="confval"} configures \[$PYTHONPATH]{.title-ref} earlier than before during the initialization process, which now also affects plugins loaded via the \[-p]{.title-ref} command-line option. \-- by `millerdev`{.interpreted-text role="user"} - [#​11381](https://redirect.github.com/pytest-dev/pytest/issues/11381): The `type` parameter of the `parser.addini` method now accepts \["int"]{.title-ref} and `"float"` parameters, facilitating the parsing of configuration values in the configuration file. Example: ```python def pytest_addoption(parser): parser.addini("int_value", type="int", default=2, help="my int value") parser.addini("float_value", type="float", default=4.2, help="my float value") ``` The \[pytest.ini]{.title-ref} file: ```ini [pytest] int_value = 3 float_value = 5.4 ``` - [#​11525](https://redirect.github.com/pytest-dev/pytest/issues/11525): Fixtures are now clearly represented in the output as a "fixture object", not as a normal function as before, making it easy for beginners to catch mistakes such as referencing a fixture declared in the same module but not requested in the test function. \-- by `the-compiler`{.interpreted-text role="user"} and `glyphack`{.interpreted-text role="user"} - [#​12426](https://redirect.github.com/pytest-dev/pytest/issues/12426): A warning is now issued when `pytest.mark.usefixtures ref`{.interpreted-text role="ref"} is used without specifying any fixtures. Previously, empty usefixtures markers were silently ignored. - [#​12707](https://redirect.github.com/pytest-dev/pytest/issues/12707): Exception chains can be navigated when dropped into Pdb in Python 3.13+. - [#​12736](https://redirect.github.com/pytest-dev/pytest/issues/12736): Added a new attribute \[name]{.title-ref} with the fixed value \["pytest tests"]{.title-ref} to the root tag \[testsuites]{.title-ref} of the junit-xml generated by pytest. This attribute is part of many junit-xml specifications and is even part of the \[junit-10.xsd]{.title-ref} specification that pytest's implementation is based on. - [#​12943](https://redirect.github.com/pytest-dev/pytest/issues/12943): If a test fails with an exceptiongroup with a single exception, the contained exception will now be displayed in the short test summary info. - [#​12958](https://redirect.github.com/pytest-dev/pytest/issues/12958): A number of `unraisable `{.interpreted-text role="ref"} enhancements: - Set the unraisable hook as early as possible and unset it as late as possible, to collect the most possible number of unraisable exceptions. - Call the garbage collector just before unsetting the unraisable hook, to collect any straggling exceptions. - Collect multiple unraisable exceptions per test phase. - Report the `tracemalloc`{.interpreted-text role="mod"} allocation traceback (if available). - Avoid using a generator based hook to allow handling `StopIteration`{.interpreted-text role="class"} in test failures. - Report the unraisable exception as the cause of the `pytest.PytestUnraisableExceptionWarning`{.interpreted-text role="class"} exception if raised. - Compute the `repr` of the unraisable object in the unraisable hook so you get the latest information if available, and should help with resurrection of the object. - [#​13010](https://redirect.github.com/pytest-dev/pytest/issues/13010): `pytest.approx`{.interpreted-text role="func"} now can compare collections that contain numbers and non-numbers mixed. - [#​13016](https://redirect.github.com/pytest-dev/pytest/issues/13016): A number of `threadexception `{.interpreted-text role="ref"} enhancements: - Set the excepthook as early as possible and unset it as late as possible, to collect the most possible number of unhandled exceptions from threads. - Collect multiple thread exceptions per test phase. - Report the `tracemalloc`{.interpreted-text role="mod"} allocation traceback (if available). - Avoid using a generator based hook to allow handling `StopIteration`{.interpreted-text role="class"} in test failures. - Report the thread exception as the cause of the `pytest.PytestUnhandledThreadExceptionWarning`{.interpreted-text role="class"} exception if raised. - Extract the `name` of the thread object in the excepthook which should help with resurrection of the thread. - [#​13031](https://redirect.github.com/pytest-dev/pytest/issues/13031): An empty parameter set as in `pytest.mark.parametrize([], ids=idfunc)` will no longer trigger a call to `idfunc` with internal objects. - [#​13115](https://redirect.github.com/pytest-dev/pytest/issues/13115): Allows supplying `ExceptionGroup[Exception]` and `BaseExceptionGroup[BaseException]` to `pytest.raises` to keep full typing on `ExceptionInfo `{.interpreted-text role="class"}: ```python with pytest.raises(ExceptionGroup[Exception]) as exc_info: some_function() ``` Parametrizing with other exception types remains an error - we do not check the types of child exceptions and thus do not permit code that might look like we do. - [#​13122](https://redirect.github.com/pytest-dev/pytest/issues/13122): The `--stepwise` mode received a number of improvements: - It no longer forgets the last failed test in case pytest is executed later without the flag. This enables the following workflow: 1. Execute pytest with `--stepwise`, pytest then stops at the first failing test; 2. Iteratively update the code and run the test in isolation, without the `--stepwise` flag (for example in an IDE), until it is fixed. 3. Execute pytest with `--stepwise` again and pytest will continue from the previously failed test, and if it passes, continue on to the next tests. Previously, at step 3, pytest would start from the beginning, forgetting the previously failed test. This change however might cause issues if the `--stepwise` mode is used far apart in time, as the state might get stale, so the internal state will be reset automatically in case the test suite changes (for now only the number of tests are considered for this, we might change/improve this on the future). - New `--stepwise-reset`/`--sw-reset` flag, allowing the user to explicitly reset the stepwise state and restart the workflow from the beginning. - [#​13308](https://redirect.github.com/pytest-dev/pytest/issues/13308): Added official support for Python 3.14. - [#​13380](https://redirect.github.com/pytest-dev/pytest/issues/13380): Fix `ExceptionGroup`{.interpreted-text role="class"} traceback filtering to exclude pytest internals. - [#​13415](https://redirect.github.com/pytest-dev/pytest/issues/13415): The author metadata of the BibTex example is now correctly formatted with last names following first names. An example of BibLaTex has been added. BibTex and BibLaTex examples now clearly indicate that what is cited is software. \-- by `willynilly`{.interpreted-text role="user"} - [#​13420](https://redirect.github.com/pytest-dev/pytest/issues/13420): Improved test collection performance by optimizing path resolution used in `FSCollector`. - [#​13457](https://redirect.github.com/pytest-dev/pytest/issues/13457): The error message about duplicate parametrization no longer displays an internal stack trace. - [#​4112](https://redirect.github.com/pytest-dev/pytest/issues/4112): Using `pytest.mark.usefixtures `{.interpreted-text role="ref"} on `pytest.param`{.interpreted-text role="func"} now produces an error instead of silently doing nothing. - [#​5473](https://redirect.github.com/pytest-dev/pytest/issues/5473): Replace \[:]{.title-ref} with \[;]{.title-ref} in the assertion rewrite warning message so it can be filtered using standard Python warning filters before calling `pytest.main`{.interpreted-text role="func"}. - [#​6985](https://redirect.github.com/pytest-dev/pytest/issues/6985): Improved `pytest.approx`{.interpreted-text role="func"} to enhance the readability of value ranges and tolerances between 0.001 and 1000. : - The [repr]{.title-ref} method now provides clearer output for values within those ranges, making it easier to interpret the results. - Previously, the output for those ranges of values and tolerances was displayed in scientific notation (e.g., [42 Β± 1.0e+00]{.title-ref}). The updated method now presents the tolerance as a decimal for better readability (e.g., [42 Β± 1]{.title-ref}). Example: **Previous Output:** ``` console >>> pytest.approx(42, abs=1) 42 Β± 1.0e+00 ``` **Current Output:** ``` console >>> pytest.approx(42, abs=1) 42 Β± 1 ``` \-- by `fazeelghafoor`{.interpreted-text role="user"} - [#​7683](https://redirect.github.com/pytest-dev/pytest/issues/7683): The formerly optional `pygments` dependency is now required, causing output always to be source-highlighted (unless disabled via the `--code-highlight=no` CLI option). #### Bug fixes - [#​10404](https://redirect.github.com/pytest-dev/pytest/issues/10404): Apply filterwarnings from config/cli as soon as possible, and revert them as late as possible so that warnings as errors are collected throughout the pytest run and before the unraisable and threadexcept hooks are removed. This allows very late warnings and unraisable/threadexcept exceptions to fail the test suite. This also changes the warning that the lsof plugin issues from PytestWarning to the new warning PytestFDWarning so it can be more easily filtered. - [#​11067](https://redirect.github.com/pytest-dev/pytest/issues/11067): The test report is now consistent regardless if the test xfailed via `pytest.mark.xfail `{.interpreted-text role="ref"} or `pytest.fail`{.interpreted-text role="func"}. Previously, *xfailed* tests via the marker would have the string `"reason: "` prefixed to the message, while those *xfailed* via the function did not. The prefix has been removed. - [#​12008](https://redirect.github.com/pytest-dev/pytest/issues/12008): In `11220`{.interpreted-text role="pr"}, an unintended change in reordering was introduced by changing the way indices were assigned to direct params. More specifically, before that change, the indices of direct params to metafunc's callspecs were assigned after all parametrizations took place. Now, that change is reverted. - [#​12863](https://redirect.github.com/pytest-dev/pytest/issues/12863): Fix applying markers, including `pytest.mark.parametrize `{.interpreted-text role="ref"} when placed above \[[@​staticmethod](https://redirect.github.com/staticmethod)]{.title-ref} or \[[@​classmethod](https://redirect.github.com/classmethod)]{.title-ref}. - [#​12929](https://redirect.github.com/pytest-dev/pytest/issues/12929): Handle StopIteration from test cases, setup and teardown correctly. - [#​12938](https://redirect.github.com/pytest-dev/pytest/issues/12938): Fixed `--durations-min` argument not respected if `-vv` is used. - [#​12946](https://redirect.github.com/pytest-dev/pytest/issues/12946): Fixed missing help for `pdb`{.interpreted-text role="mod"} commands wrapped by pytest -- by `adamchainz`{.interpreted-text role="user"}. - [#​12981](https://redirect.github.com/pytest-dev/pytest/issues/12981): Prevent exceptions in `pytest.Config.add_cleanup`{.interpreted-text role="func"} callbacks preventing further cleanups. - [#​13047](https://redirect.github.com/pytest-dev/pytest/issues/13047): Restore `pytest.approx`{.interpreted-text role="func"} handling of equality checks between \[bool]{.title-ref} and \[numpy.bool\_]{.title-ref} types. Comparing \[bool]{.title-ref} and \[numpy.bool\_]{.title-ref} using `pytest.approx`{.interpreted-text role="func"} accidentally changed in version \[8.3.4]{.title-ref} and \[8.3.5]{.title-ref} to no longer match: ```pycon >>> import numpy as np >>> from pytest import approx >>> [np.True_, np.True_] == pytest.approx([True, True]) False ``` This has now been fixed: ```pycon >>> [np.True_, np.True_] == pytest.approx([True, True]) True ``` - [#​13119](https://redirect.github.com/pytest-dev/pytest/issues/13119): Improved handling of invalid regex patterns for filter warnings by providing a clear error message. - [#​13175](https://redirect.github.com/pytest-dev/pytest/issues/13175): The diff is now also highlighted correctly when comparing two strings. - [#​13248](https://redirect.github.com/pytest-dev/pytest/issues/13248): Fixed an issue where passing a `scope` in `Metafunc.parametrize `{.interpreted-text role="py:func"} with `indirect=True` could result in other fixtures being unable to depend on the parametrized fixture. - [#​13291](https://redirect.github.com/pytest-dev/pytest/issues/13291): Fixed `repr` of `attrs` objects in assertion failure messages when using `attrs>=25.2`. - [#​13312](https://redirect.github.com/pytest-dev/pytest/issues/13312): Fixed a possible `KeyError` crash on PyPy during collection of tests involving higher-scoped parameters. - [#​13345](https://redirect.github.com/pytest-dev/pytest/issues/13345): Fix type hints for `pytest.TestReport.when`{.interpreted-text role="attr"} and `pytest.TestReport.location`{.interpreted-text role="attr"}. - [#​13377](https://redirect.github.com/pytest-dev/pytest/issues/13377): Fixed handling of test methods with positional-only parameter syntax. Now, methods are supported that formally define `self` as positional-only and/or fixture parameters as keyword-only, e.g.: ```python class TestClass: def test_method(self, /, *, fixture): ... ``` Before, this caused an internal error in pytest. - [#​13384](https://redirect.github.com/pytest-dev/pytest/issues/13384): Fixed an issue where pytest could report negative durations. - [#​13420](https://redirect.github.com/pytest-dev/pytest/issues/13420): Added `lru_cache` to `nodes._check_initialpaths_for_relpath`. - [#​9037](https://redirect.github.com/pytest-dev/pytest/issues/9037): Honor `disable_test_id_escaping_and_forfeit_all_rights_to_community_support`{.interpreted-text role="confval"} when escaping ids in parametrized tests. #### Improved documentation - [#​12535](https://redirect.github.com/pytest-dev/pytest/issues/12535): \[This example]{.title-ref}<> showed `print` statements that do not exactly reflect what the different branches actually do. The fix makes the example more precise. - [#​13218](https://redirect.github.com/pytest-dev/pytest/issues/13218): Pointed out in the `pytest.approx`{.interpreted-text role="func"} documentation that it considers booleans unequal to numeric zero or one. - [#​13221](https://redirect.github.com/pytest-dev/pytest/issues/13221): Improved grouping of CLI options in the `--help` output. - [#​6649](https://redirect.github.com/pytest-dev/pytest/issues/6649): Added `~pytest.TerminalReporter`{.interpreted-text role="class"} to the `api-reference`{.interpreted-text role="ref"} documentation page. - [#​8612](https://redirect.github.com/pytest-dev/pytest/issues/8612): Add a recipe for handling abstract test classes in the documentation. A new example has been added to the documentation to demonstrate how to use a mixin class to handle abstract test classes without manually setting the `__test__` attribute for subclasses. This ensures that subclasses of abstract test classes are automatically collected by pytest. #### Packaging updates and notes for downstreams - [#​13317](https://redirect.github.com/pytest-dev/pytest/issues/13317): Specified minimum allowed versions of `colorama`, `iniconfig`, and `packaging`; and bumped the minimum allowed version of `exceptiongroup` for `python_version<'3.11'` from a release candidate to a full release. #### Contributor-facing changes - [#​12017](https://redirect.github.com/pytest-dev/pytest/issues/12017): Mixed internal improvements: - Migrate formatting to f-strings in some tests. - Use type-safe constructs in JUnitXML tests. - Moved`MockTiming` into `_pytest.timing`. \-- by `RonnyPfannschmidt`{.interpreted-text role="user"} - [#​12647](https://redirect.github.com/pytest-dev/pytest/issues/12647): Fixed running the test suite with the `hypothesis` pytest plugin. #### Miscellaneous internal changes - [#​6649](https://redirect.github.com/pytest-dev/pytest/issues/6649): Added `~pytest.TerminalReporter`{.interpreted-text role="class"} to the public pytest API, as it is part of the signature of the `pytest_terminal_summary`{.interpreted-text role="hook"} hook.
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1895bdde..418e7840 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ "test": [ "coverage>=7.8,<7.9", "pylint>=3,<3.4", - "pytest>=8,<8.4", + "pytest>=8,<8.5", "pytest-cov>=6,<6.2", "mypy>=1.16,<1.17", "types-python-dateutil", From 1bd5e98b5c8b6fead11f514e985265813334107d Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 3 Jun 2025 10:51:35 +0200 Subject: [PATCH 060/213] fix: invalid placement group id casting (#501) The placement group id should be an integer. See https://docs.hetzner.cloud/#server-actions-add-a-server-to-a-placement-group --- hcloud/servers/client.py | 2 +- tests/unit/servers/test_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index d72bf803..397b0bf2 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1227,7 +1227,7 @@ def add_to_placement_group( :param placement_group: :class:`BoundPlacementGroup ` or :class:`Network ` :return: :class:`BoundAction ` """ - data: dict[str, Any] = {"placement_group": str(placement_group.id)} + data: dict[str, Any] = {"placement_group": placement_group.id} response = self._client.request( url=f"/servers/{server.id}/actions/add_to_placement_group", method="POST", diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 8f103df7..cb69f4f6 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -506,7 +506,7 @@ def test_add_to_placement_group( hetzner_client.request.assert_called_with( url="/servers/14/actions/add_to_placement_group", method="POST", - json={"placement_group": "897"}, + json={"placement_group": 897}, ) assert action.id == 13 From 0cc58f863575b999def87e18612e838655b82593 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 10 Jun 2025 18:05:55 +0200 Subject: [PATCH 061/213] fix: handle string id when checking has_id_or_name (#504) The function did not handle the case of string ids (e.g. "2345"), we now handle this edge case. Related to https://github.com/ansible-collections/hetzner.hcloud/issues/647 Co-authored-by: Lukas Metzner --- hcloud/core/domain.py | 17 ++++++++++++----- tests/unit/core/test_domain.py | 26 ++++++++++++++++---------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index f3050301..b2ac1972 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -52,15 +52,22 @@ def has_id_or_name(self, id_or_name: int | str) -> bool: the comparison will not work as expected (e.g. the domains are the same but cannot be equal, if one provides an id and the other the name). """ - values: list[int | str] = [] + result = None + if self.id is not None: - values.append(self.id) + value = id_or_name + if isinstance(id_or_name, str) and id_or_name.isnumeric(): + value = int(id_or_name) + + result = result or self.id == value + if self.name is not None: - values.append(self.name) - if not values: + result = result or self.name == str(id_or_name) + + if result is None: raise ValueError("id or name must be set") - return id_or_name in values + return result class Pagination(BaseDomain): diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index 484799f6..73cab892 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -71,19 +71,25 @@ def test_id_or_name_exception(self): assert str(error) == "id or name must be set" @pytest.mark.parametrize( - "other, expected", + "domain, id_or_name, expected", [ - (SomeDomain(id=1), True), - (SomeDomain(name="name1"), True), - (SomeDomain(id=1, name="name1"), True), - (SomeDomain(id=2), False), - (SomeDomain(name="name2"), False), - (SomeDomain(id=2, name="name2"), False), + (SomeDomain(id=1, name="name1"), 1, True), + (SomeDomain(id=1, name="name1"), "1", True), + (SomeDomain(id=1, name="name1"), "name1", True), + (SomeDomain(id=1, name="name1"), 2, False), + (SomeDomain(id=1, name="name1"), "2", False), + (SomeDomain(id=1, name="name1"), "name2", False), + (SomeDomain(id=1, name="3"), 3, True), + (SomeDomain(id=3, name="1"), "3", True), ], ) - def test_has_id_or_name_exception(self, other, expected): - domain = SomeDomain(id=1, name="name1") - assert domain.has_id_or_name(other.id_or_name) == expected + def test_has_id_or_name( + self, + domain: SomeDomain, + id_or_name: str | int, + expected: bool, + ): + assert domain.has_id_or_name(id_or_name) == expected class ActionDomain(BaseDomain, DomainIdentityMixin): From 7a2eabc68aec3cf0fabfeb559ab620bd93160b0f Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Tue, 10 Jun 2025 18:14:06 +0200 Subject: [PATCH 062/213] chore(main): release v2.5.3 (#503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - invalid placement group id casting (#501) - handle string id when checking has_id_or_name (#504) ---

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: releaser-pleaser <> --- CHANGELOG.md | 7 +++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 067145aa..07c79bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v2.5.3](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.3) + +### Bug Fixes + +- invalid placement group id casting (#501) +- handle string id when checking has_id_or_name (#504) + ## [v2.5.2](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.2) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index e93dacf8..7252467c 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.5.2" # x-releaser-pleaser-version +__version__ = "2.5.3" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 418e7840..11ce0d46 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.5.2", # x-releaser-pleaser-version + version="2.5.3", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From e53438ff28a8fe48dc67aca92e00bb67dc4e8f6c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:50:26 +0200 Subject: [PATCH 063/213] chore(deps): update apricote/releaser-pleaser action to v0.6.0 (#507) 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 | |---|---|---|---| | [apricote/releaser-pleaser](https://redirect.github.com/apricote/releaser-pleaser) | action | minor | `v0.5.1` -> `v0.6.0` | --- ### Release Notes
apricote/releaser-pleaser (apricote/releaser-pleaser) ### [`v0.6.0`](https://redirect.github.com/apricote/releaser-pleaser/blob/HEAD/CHANGELOG.md#v060) [Compare Source](https://redirect.github.com/apricote/releaser-pleaser/compare/v0.5.1...v0.6.0) ##### ✨ Highlights ##### Reduced resource usage `releaser-pleaser` now uses less resources: - It now skips pushing changes to the release pull request if they are only a rebase. - The configurations for GitHub Actions and GitLab CI/CD now makes sure that only a single job is running at the same time. On GitHub unnecessary/duplicate jobs are also automatically aborted. - It handles the stop signals from the CI environment and tries to exit quickly. \`\`\`yaml concurrency: group: releaser-pleaser cancel-in-progress: true \`\`\` ##### Avoid losing manual edits to release pull request Before, releaser-pleaser was prone to overwriting user changes to the release pull request if they were made after releaser-pleaser already started running. There is now an additional check right before submitting the changes to see if the description changed, and retry if it did. ##### Proper commit authorship Before, the release commits were created by `releaser-pleaser <>`. This was ugly to look at. We now check for details on the API user used to talk to the forge, and use that users details instead as the commit author. The committer is still `releaser-pleaser`. ##### Features - real user as commit author ([#​187](https://redirect.github.com/apricote/releaser-pleaser/issues/187)) - avoid pushing release branch only for rebasing ([#​114](https://redirect.github.com/apricote/releaser-pleaser/issues/114)) - colorize log output ([#​195](https://redirect.github.com/apricote/releaser-pleaser/issues/195)) - graceful shutdown when CI job is cancelled ([#​196](https://redirect.github.com/apricote/releaser-pleaser/issues/196)) - detect changed pull request description and retry process ([#​197](https://redirect.github.com/apricote/releaser-pleaser/issues/197)) - run one job concurrently to reduce chance of conflicts ([#​198](https://redirect.github.com/apricote/releaser-pleaser/issues/198)) ##### Bug Fixes - crash when running in repo without any tags ([#​190](https://redirect.github.com/apricote/releaser-pleaser/issues/190))
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 317a08c0..5bcf138b 100644 --- a/.github/workflows/releaser-pleaser.yml +++ b/.github/workflows/releaser-pleaser.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: releaser-pleaser - uses: apricote/releaser-pleaser@v0.5.1 + uses: apricote/releaser-pleaser@v0.6.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From da5d905def7226f09ce7ee8b6e71375a733edf68 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:50:36 +0200 Subject: [PATCH 064/213] chore(deps): update dependency pytest-cov to >=6,<6.3 (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [pytest-cov](https://redirect.github.com/pytest-dev/pytest-cov) ([changelog](https://pytest-cov.readthedocs.io/en/latest/changelog.html)) | `>=6,<6.2` -> `>=6,<6.3` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest-cov/6.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/pytest-cov/6.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/pytest-cov/6.1.1/6.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest-cov/6.1.1/6.2.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
pytest-dev/pytest-cov (pytest-cov) ### [`v6.2.1`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#621-2025-06-12) [Compare Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.2.0...v6.2.1) - Added a version requirement for pytest's pluggy dependency (1.2.0, released 2023-06-21) that has the required new-style hookwrapper API. - Removed deprecated license classifier (packaging). - Disabled coverage warnings in two more situations where they have no value: - "module-not-measured" in workers - "already-imported" in subprocesses ### [`v6.2.0`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#620-2025-06-11) [Compare Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.1.1...v6.2.0) - The plugin now adds 3 rules in the filter warnings configuration to prevent common coverage warnings being raised as obscure errors:: default:unclosed database in \ --- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 11ce0d46..4baf6e30 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "coverage>=7.8,<7.9", "pylint>=3,<3.4", "pytest>=8,<8.5", - "pytest-cov>=6,<6.2", + "pytest-cov>=6,<6.3", "mypy>=1.16,<1.17", "types-python-dateutil", "types-requests", From 640ed7f84a7ee4d5e2b181bb90e9430de3470d47 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 15:50:47 +0200 Subject: [PATCH 065/213] chore(deps): update dependency coverage to >=7.9,<7.10 (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Adoption | Passing | Confidence | |---|---|---|---|---|---| | [coverage](https://redirect.github.com/nedbat/coveragepy) | `>=7.8,<7.9` -> `>=7.9,<7.10` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/coverage/7.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/pypi/coverage/7.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/pypi/coverage/7.8.2/7.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/coverage/7.8.2/7.9.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
nedbat/coveragepy (coverage) ### [`v7.9.0`](https://redirect.github.com/nedbat/coveragepy/blob/HEAD/CHANGES.rst#Version-790--2025-06-11) [Compare Source](https://redirect.github.com/nedbat/coveragepy/compare/7.8.2...7.9.0) - Added a `[run] core` configuration setting to specify the measurement core, which was previously only available through the COVERAGE_CORE environment variable. Finishes `issue 1746`\_. - Fixed incorrect rendering of f-strings with doubled braces, closing `issue 1980`\_. - If the C tracer core can't be imported, a warning ("no-ctracer") is issued with the reason. - The C tracer core extension module now conforms to `PEP 489`*, closing `issue 1977`*. Thanks, `Adam Turner `\_. - Fixed a "ValueError: min() arg is an empty sequence" error caused by strange empty modules, found by `oss-fuzz`\_. .. \_issue 1746:[https://github.com/nedbat/coveragepy/issues/1746](https://redirect.github.com/nedbat/coveragepy/issues/1746)6 .. \_issue 1977[https://github.com/nedbat/coveragepy/issues/1977](https://redirect.github.com/nedbat/coveragepy/issues/1977)77 .. \_pull 197[https://github.com/nedbat/coveragepy/pull/1978](https://redirect.github.com/nedbat/coveragepy/pull/1978)978 .. \_issue 19[https://github.com/nedbat/coveragepy/issues/1980](https://redirect.github.com/nedbat/coveragepy/issues/1980)1980 .. \_PEP 489: https://peps.python.org/pep-0489 .. \_oss-fuzz: https://google.github.io/oss-fuzz/ .. \_changes\_7-8-2:
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4baf6e30..d613a17e 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.8,<7.9", + "coverage>=7.9,<7.10", "pylint>=3,<3.4", "pytest>=8,<8.5", "pytest-cov>=6,<6.3", From 24f50087f7d6a8c9268987bde4e73167bc8e3f29 Mon Sep 17 00:00:00 2001 From: jo Date: Thu, 19 Jun 2025 12:18:10 +0200 Subject: [PATCH 066/213] ci: update releaser-pleaser workflow (sweep) --- .github/workflows/releaser-pleaser.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 5bcf138b..313182ed 100644 --- a/.github/workflows/releaser-pleaser.yml +++ b/.github/workflows/releaser-pleaser.yml @@ -9,6 +9,10 @@ on: - labeled - unlabeled +concurrency: + group: releaser-pleaser + cancel-in-progress: true + jobs: releaser-pleaser: # Do not run on forks. From 47134e164c69640367dad632d0e88201071b94b6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 21 Jun 2025 02:05:42 +0000 Subject: [PATCH 067/213] chore(deps): update pre-commit hook pycqa/flake8 to v7.3.0 (#509) 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 | |---|---|---|---| | [pycqa/flake8](https://redirect.github.com/pycqa/flake8) | repository | minor | `7.2.0` -> `7.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
pycqa/flake8 (pycqa/flake8) ### [`v7.3.0`](https://redirect.github.com/pycqa/flake8/compare/7.2.0...7.3.0) [Compare Source](https://redirect.github.com/pycqa/flake8/compare/7.2.0...7.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 23d234b7..4aedcd34 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,6 +46,6 @@ repos: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.2.0 + rev: 7.3.0 hooks: - id: flake8 From 8491feacf747c1f76fb07867cb79e81754cffe97 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 25 Jun 2025 10:33:29 +0200 Subject: [PATCH 068/213] fix: typo in `LoadBalancerHealthCheckHttp` class name (#511) Rename the class to fix the typo, and keep an alias for backward compatibility. --- docs/api.clients.load_balancers.rst | 2 +- hcloud/load_balancers/__init__.py | 2 ++ hcloud/load_balancers/client.py | 4 ++-- hcloud/load_balancers/domain.py | 34 +++++++++++++++++++++++++---- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/api.clients.load_balancers.rst b/docs/api.clients.load_balancers.rst index 771eadfc..dfb2780a 100644 --- a/docs/api.clients.load_balancers.rst +++ b/docs/api.clients.load_balancers.rst @@ -20,7 +20,7 @@ LoadBalancerClient .. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealthCheck :members: -.. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealtCheckHttp +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp :members: .. autoclass:: hcloud.load_balancers.domain.LoadBalancerTarget diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 33745163..f84c7e20 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -14,6 +14,7 @@ LoadBalancerAlgorithm, LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, @@ -33,6 +34,7 @@ "LoadBalancer", "LoadBalancerAlgorithm", "LoadBalancerHealtCheckHttp", + "LoadBalancerHealthCheckHttp", "LoadBalancerHealthCheck", "LoadBalancerService", "LoadBalancerServiceHttp", diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index aeeeb879..2de1c2d7 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -20,8 +20,8 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, - LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, @@ -139,7 +139,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru timeout=service["health_check"]["timeout"], ) if tmp_service.health_check.protocol != "tcp": - tmp_service.health_check.http = LoadBalancerHealtCheckHttp( + tmp_service.health_check.http = LoadBalancerHealthCheckHttp( domain=service["health_check"]["http"]["domain"], path=service["health_check"]["http"]["path"], response=service["health_check"]["http"]["response"], diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index d1a370ae..fb047a19 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any, Literal from dateutil.parser import isoparse @@ -256,7 +257,7 @@ class LoadBalancerHealthCheck(BaseDomain): Timeout in sec after a try is assumed as timeout :param retries: int Retries we perform until we assume a target as unhealthy - :param http: LoadBalancerHealtCheckHttp + :param http: LoadBalancerHealthCheckHttp HTTP Config """ @@ -267,7 +268,7 @@ def __init__( interval: int | None = None, timeout: int | None = None, retries: int | None = None, - http: LoadBalancerHealtCheckHttp | None = None, + http: LoadBalancerHealthCheckHttp | None = None, ): self.protocol = protocol self.port = port @@ -277,8 +278,8 @@ def __init__( self.http = http -class LoadBalancerHealtCheckHttp(BaseDomain): - """LoadBalancerHealtCheckHttp Domain +class LoadBalancerHealthCheckHttp(BaseDomain): + """LoadBalancerHealthCheckHttp Domain :param domain: str Domain name to send in HTTP request. Can be null: In that case we will not send a domain name @@ -307,6 +308,31 @@ def __init__( self.tls = tls +class LoadBalancerHealtCheckHttp(LoadBalancerHealthCheckHttp): + """ + Kept for backward compatibility. + + .. deprecated:: 2.5.4 + Use :class:``hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp`` instead. + """ + + def __init__( + self, + domain: str | None = None, + path: str | None = None, + response: str | None = None, + status_codes: list | None = None, + tls: bool | None = None, + ): + warnings.warn( + "The 'hcloud.load_balancers.domain.LoadBalancerHealtCheckHttp' class is deprecated, please use the " + "'hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp' class instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(domain, path, response, status_codes, tls) + + class LoadBalancerTarget(BaseDomain): """LoadBalancerTarget Domain From bcbdd7dd6ccc9bd43a4d1d7a9ccc0865b1db3dc2 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 25 Jun 2025 11:45:13 +0200 Subject: [PATCH 069/213] fix: equality for some domain classes (#510) Some domains were missing the `__api_properties__` and `__slots__` properties required for the equality check. --- hcloud/certificates/domain.py | 13 +++++ hcloud/load_balancers/domain.py | 53 +++++++++++++++++++ tests/unit/actions/test_domain.py | 10 ++++ tests/unit/certificates/test_domain.py | 20 ++++++- tests/unit/datacenters/test_domain.py | 16 ++++++ tests/unit/deprecation/__init__.py | 0 tests/unit/deprecation/test_domain.py | 15 ++++++ tests/unit/firewalls/test_domain.py | 24 ++++++++- tests/unit/floating_ips/test_domain.py | 12 +++++ tests/unit/images/test_domain.py | 12 +++++ tests/unit/isos/test_domain.py | 10 ++++ tests/unit/load_balancer_types/test_domain.py | 15 ++++++ tests/unit/load_balancers/test_domain.py | 48 ++++++++++++++++- tests/unit/locations/test_domain.py | 15 ++++++ tests/unit/networks/test_domain.py | 16 +++++- tests/unit/placement_groups/test_domain.py | 12 +++++ tests/unit/primary_ips/test_domain.py | 12 +++++ tests/unit/server_types/test_domain.py | 15 ++++++ tests/unit/servers/test_domain.py | 36 ++++++++++++- tests/unit/ssh_keys/test_domain.py | 12 +++++ tests/unit/volumes/test_domain.py | 12 +++++ 21 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 tests/unit/datacenters/test_domain.py create mode 100644 tests/unit/deprecation/__init__.py create mode 100644 tests/unit/deprecation/test_domain.py create mode 100644 tests/unit/load_balancer_types/test_domain.py create mode 100644 tests/unit/locations/test_domain.py create mode 100644 tests/unit/server_types/test_domain.py diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py index 2434a745..6b231ed1 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -87,6 +87,13 @@ class ManagedCertificateStatus(BaseDomain): If issuance or renewal reports failure, this property contains information about what happened """ + __api_properties__ = ( + "issuance", + "renewal", + "error", + ) + __slots__ = __api_properties__ + def __init__( self, issuance: str | None = None, @@ -107,6 +114,12 @@ class ManagedCertificateError(BaseDomain): Message detailing the error """ + __api_properties__ = ( + "code", + "message", + ) + __slots__ = __api_properties__ + def __init__(self, code: str | None = None, message: str | None = None): self.code = code self.message = message diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index fb047a19..6a24547f 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -229,6 +229,15 @@ class LoadBalancerServiceHttp(BaseDomain): Use sticky sessions. Only available if protocol is "http" or "https". """ + __api_properties__ = ( + "cookie_name", + "cookie_lifetime", + "certificates", + "redirect_http", + "sticky_sessions", + ) + __slots__ = __api_properties__ + def __init__( self, cookie_name: str | None = None, @@ -261,6 +270,16 @@ class LoadBalancerHealthCheck(BaseDomain): HTTP Config """ + __api_properties__ = ( + "protocol", + "port", + "interval", + "timeout", + "retries", + "http", + ) + __slots__ = __api_properties__ + def __init__( self, protocol: str | None = None, @@ -293,6 +312,15 @@ class LoadBalancerHealthCheckHttp(BaseDomain): Type of health check """ + __api_properties__ = ( + "domain", + "path", + "response", + "status_codes", + "tls", + ) + __slots__ = __api_properties__ + def __init__( self, domain: str | None = None, @@ -350,6 +378,16 @@ class LoadBalancerTarget(BaseDomain): List of health statuses of the services on this target. Only present for target types "server" and "ip". """ + __api_properties__ = ( + "type", + "server", + "label_selector", + "ip", + "use_private_ip", + "health_status", + ) + __slots__ = __api_properties__ + def __init__( self, type: str | None = None, @@ -401,6 +439,12 @@ class LoadBalancerTargetHealthStatus(BaseDomain): :param status: Load Balancer Target status. Choices: healthy, unhealthy, unknown """ + __api_properties__ = ( + "listen_port", + "status", + ) + __slots__ = __api_properties__ + def __init__( self, listen_port: int | None = None, @@ -416,6 +460,9 @@ class LoadBalancerTargetLabelSelector(BaseDomain): :param selector: str Target label selector """ + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + def __init__(self, selector: str | None = None): self.selector = selector @@ -426,6 +473,9 @@ class LoadBalancerTargetIP(BaseDomain): :param ip: str Target IP """ + __api_properties__ = ("ip",) + __slots__ = __api_properties__ + def __init__(self, ip: str | None = None): self.ip = ip @@ -437,6 +487,9 @@ class LoadBalancerAlgorithm(BaseDomain): Algorithm of the Load Balancer. Choices: round_robin, least_connections """ + __api_properties__ = ("type",) + __slots__ = __api_properties__ + def __init__(self, type: str | None = None): self.type = type diff --git a/tests/unit/actions/test_domain.py b/tests/unit/actions/test_domain.py index 5133a240..c5efcde5 100644 --- a/tests/unit/actions/test_domain.py +++ b/tests/unit/actions/test_domain.py @@ -13,6 +13,16 @@ ) +@pytest.mark.parametrize( + "value", + [ + (Action(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestAction: def test_started_finished_is_datetime(self): action = Action( diff --git a/tests/unit/certificates/test_domain.py b/tests/unit/certificates/test_domain.py index df916964..d65a61ee 100644 --- a/tests/unit/certificates/test_domain.py +++ b/tests/unit/certificates/test_domain.py @@ -3,7 +3,25 @@ import datetime from datetime import timezone -from hcloud.certificates import Certificate +import pytest + +from hcloud.certificates import ( + Certificate, + ManagedCertificateError, + ManagedCertificateStatus, +) + + +@pytest.mark.parametrize( + "value", + [ + (Certificate(id=1),), + (ManagedCertificateError()), + (ManagedCertificateStatus()), + ], +) +def test_eq(value): + assert value == value class TestCertificate: diff --git a/tests/unit/datacenters/test_domain.py b/tests/unit/datacenters/test_domain.py new file mode 100644 index 00000000..09ed99ed --- /dev/null +++ b/tests/unit/datacenters/test_domain.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + +from hcloud.datacenters import Datacenter, DatacenterServerTypes + + +@pytest.mark.parametrize( + "value", + [ + (Datacenter(id=1),), + (DatacenterServerTypes(available=[], available_for_migration=[], supported=[])), + ], +) +def test_eq(value): + assert value == value diff --git a/tests/unit/deprecation/__init__.py b/tests/unit/deprecation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/deprecation/test_domain.py b/tests/unit/deprecation/test_domain.py new file mode 100644 index 00000000..35c8473f --- /dev/null +++ b/tests/unit/deprecation/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.deprecation import DeprecationInfo + + +@pytest.mark.parametrize( + "value", + [ + (DeprecationInfo(),), + ], +) +def test_eq(value): + assert value == value diff --git a/tests/unit/firewalls/test_domain.py b/tests/unit/firewalls/test_domain.py index 6c641222..ec303c14 100644 --- a/tests/unit/firewalls/test_domain.py +++ b/tests/unit/firewalls/test_domain.py @@ -3,7 +3,29 @@ import datetime from datetime import timezone -from hcloud.firewalls import Firewall +import pytest + +from hcloud.firewalls import ( + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + FirewallResourceLabelSelector, + FirewallRule, +) + + +@pytest.mark.parametrize( + "value", + [ + (Firewall(id=1),), + (FirewallRule(direction="in", protocol="icmp", source_ips=[]),), + (FirewallResource(type="server"),), + (FirewallResourceAppliedToResources(type="server"),), + (FirewallResourceLabelSelector(),), + ], +) +def test_eq(value): + assert value == value class TestFirewall: diff --git a/tests/unit/floating_ips/test_domain.py b/tests/unit/floating_ips/test_domain.py index 69e50161..a16a25b4 100644 --- a/tests/unit/floating_ips/test_domain.py +++ b/tests/unit/floating_ips/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.floating_ips import FloatingIP +@pytest.mark.parametrize( + "value", + [ + (FloatingIP(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestFloatingIP: def test_created_is_datetime(self): floatingIP = FloatingIP(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/images/test_domain.py b/tests/unit/images/test_domain.py index 5c768877..d85c8a18 100644 --- a/tests/unit/images/test_domain.py +++ b/tests/unit/images/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.images import Image +@pytest.mark.parametrize( + "value", + [ + (Image(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestImage: def test_created_is_datetime(self): image = Image(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/isos/test_domain.py b/tests/unit/isos/test_domain.py index 5017b529..c056f3b7 100644 --- a/tests/unit/isos/test_domain.py +++ b/tests/unit/isos/test_domain.py @@ -7,6 +7,16 @@ from hcloud.isos import Iso +@pytest.mark.parametrize( + "value", + [ + (Iso(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestIso: @pytest.fixture() def deprecated_iso(self): diff --git a/tests/unit/load_balancer_types/test_domain.py b/tests/unit/load_balancer_types/test_domain.py new file mode 100644 index 00000000..d3b8eff7 --- /dev/null +++ b/tests/unit/load_balancer_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.load_balancer_types import LoadBalancerType + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancerType(id=1),), + ], +) +def test_eq(value): + assert value == value diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 24bed108..37d1b981 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -3,7 +3,53 @@ import datetime from datetime import timezone -from hcloud.load_balancers import LoadBalancer +import pytest + +from hcloud.load_balancers import ( + IPv4Address, + IPv6Network, + LoadBalancer, + LoadBalancerAlgorithm, + LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, + LoadBalancerService, + LoadBalancerServiceHttp, + LoadBalancerTarget, + LoadBalancerTargetHealthStatus, + LoadBalancerTargetIP, + LoadBalancerTargetLabelSelector, + PrivateNet, + PublicNetwork, +) + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancer(id=1),), + (LoadBalancerService,), + (LoadBalancerServiceHttp(),), + (LoadBalancerHealthCheck(),), + (LoadBalancerHealthCheckHttp(),), + (LoadBalancerTarget(),), + (LoadBalancerTargetHealthStatus(),), + (LoadBalancerTargetLabelSelector(),), + (LoadBalancerTargetIP(),), + (LoadBalancerAlgorithm(),), + ( + PublicNetwork( + ipv4=IPv4Address(ip="127.0.0.1", dns_ptr="example.com"), + ipv6=IPv6Network("2001:0db8::0/64", dns_ptr="example.com"), + enabled=True, + ), + ), + (IPv4Address(ip="127.0.0.1", dns_ptr="example.com"),), + (IPv6Network("2001:0db8::0/64", dns_ptr="example.com"),), + (PrivateNet(network=object(), ip="127.0.0.1"),), + ], +) +def test_eq(value): + assert value == value class TestLoadBalancers: diff --git a/tests/unit/locations/test_domain.py b/tests/unit/locations/test_domain.py new file mode 100644 index 00000000..fd5d710a --- /dev/null +++ b/tests/unit/locations/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.locations import Location + + +@pytest.mark.parametrize( + "value", + [ + (Location(id=1),), + ], +) +def test_eq(value): + assert value == value diff --git a/tests/unit/networks/test_domain.py b/tests/unit/networks/test_domain.py index 1e5f10e3..187de5fb 100644 --- a/tests/unit/networks/test_domain.py +++ b/tests/unit/networks/test_domain.py @@ -3,7 +3,21 @@ import datetime from datetime import timezone -from hcloud.networks import Network +import pytest + +from hcloud.networks import Network, NetworkRoute, NetworkSubnet + + +@pytest.mark.parametrize( + "value", + [ + (Network(id=1),), + (NetworkSubnet(ip_range="10.0.1.0/24"),), + (NetworkRoute(destination="10.0.1.2", gateway="10.0.1.1"),), + ], +) +def test_eq(value): + assert value == value class TestNetwork: diff --git a/tests/unit/placement_groups/test_domain.py b/tests/unit/placement_groups/test_domain.py index 91fd0c3d..f8293fcc 100644 --- a/tests/unit/placement_groups/test_domain.py +++ b/tests/unit/placement_groups/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.placement_groups import PlacementGroup +@pytest.mark.parametrize( + "value", + [ + (PlacementGroup(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestPlacementGroup: def test_created_is_datetime(self): placement_group = PlacementGroup(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/primary_ips/test_domain.py b/tests/unit/primary_ips/test_domain.py index 945522fd..5adcf226 100644 --- a/tests/unit/primary_ips/test_domain.py +++ b/tests/unit/primary_ips/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.primary_ips import PrimaryIP +@pytest.mark.parametrize( + "value", + [ + (PrimaryIP(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestPrimaryIP: def test_created_is_datetime(self): primaryIP = PrimaryIP(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/server_types/test_domain.py b/tests/unit/server_types/test_domain.py new file mode 100644 index 00000000..4d7a28c2 --- /dev/null +++ b/tests/unit/server_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.server_types import ServerType + + +@pytest.mark.parametrize( + "value", + [ + (ServerType(id=1),), + ], +) +def test_eq(value): + assert value == value diff --git a/tests/unit/servers/test_domain.py b/tests/unit/servers/test_domain.py index a4d10b63..fdc05335 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -3,7 +3,41 @@ import datetime from datetime import timezone -from hcloud.servers import Server +import pytest + +from hcloud.servers import ( + IPv4Address, + IPv6Network, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServerCreatePublicNetwork, +) + + +@pytest.mark.parametrize( + "value", + [ + (Server(id=1),), + ( + PublicNetwork( + ipv4=None, + ipv6=None, + floating_ips=[], + primary_ipv4=None, + primary_ipv6=None, + ), + ), + (PublicNetworkFirewall(firewall=object(), status="pending"),), + (IPv4Address(ip="127.0.0.1", blocked=False, dns_ptr="example.com"),), + (IPv6Network("2001:0db8::0/64", blocked=False, dns_ptr="example.com"),), + (PrivateNet(network=object(), ip="127.0.0.1", alias_ips=[], mac_address=""),), + (ServerCreatePublicNetwork(),), + ], +) +def test_eq(value): + assert value == value class TestServer: diff --git a/tests/unit/ssh_keys/test_domain.py b/tests/unit/ssh_keys/test_domain.py index edcf7526..e354128e 100644 --- a/tests/unit/ssh_keys/test_domain.py +++ b/tests/unit/ssh_keys/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.ssh_keys import SSHKey +@pytest.mark.parametrize( + "value", + [ + (SSHKey(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestSSHKey: def test_created_is_datetime(self): sshKey = SSHKey(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/volumes/test_domain.py b/tests/unit/volumes/test_domain.py index 3844f2de..f69329d9 100644 --- a/tests/unit/volumes/test_domain.py +++ b/tests/unit/volumes/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.volumes import Volume +@pytest.mark.parametrize( + "value", + [ + (Volume(id=1),), + ], +) +def test_eq(value): + assert value == value + + class TestVolume: def test_created_is_datetime(self): volume = Volume(id=1, created="2016-01-30T23:50+00:00") From a813f93128067cd18712a9f318eb1e3c07cfa2f6 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 9 Jul 2025 11:38:30 +0200 Subject: [PATCH 070/213] fix: use valid license identifier (SPDX) (#514) Fixes #513 ``` ******************************************************************************** Please consider removing the following classifiers in favor of a SPDX license expression: License :: OSI Approved :: MIT License See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license for details. ******************************************************************************** ``` See https://github.com/hetznercloud/hcloud-python/actions/runs/15873016015/job/44753884445#step:5:24 --- setup.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/setup.py b/setup.py index d613a17e..ebedfa57 100644 --- a/setup.py +++ b/setup.py @@ -21,11 +21,10 @@ "Changelog": "https://github.com/hetznercloud/hcloud-python/blob/main/CHANGELOG.md", "Source Code": "https://github.com/hetznercloud/hcloud-python", }, - license="MIT license", + license="MIT", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", From ca250a97d02bc46efd4a3c8dbd34b5b6aad4e12b Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:50:16 +0200 Subject: [PATCH 071/213] chore(main): release v2.5.4 (#512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - typo in `LoadBalancerHealthCheckHttp` class name (#511) - equality for some domain classes (#510) - use valid license identifier (SPDX) (#514) ---

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 07c79bbf..5cd0d529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [v2.5.4](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.4) + +### Bug Fixes + +- typo in `LoadBalancerHealthCheckHttp` class name (#511) +- equality for some domain classes (#510) +- use valid license identifier (SPDX) (#514) + ## [v2.5.3](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.3) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index 7252467c..b8c5f786 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.5.3" # x-releaser-pleaser-version +__version__ = "2.5.4" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index ebedfa57..c3b6be63 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.5.3", # x-releaser-pleaser-version + version="2.5.4", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From a2f4eaf4c4d9ea4b76bb8bbf8b5be2faf37f92d7 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 10 Jul 2025 15:22:41 +0200 Subject: [PATCH 072/213] test: ignore our own deprecation warnings (#516) Fixes #515 --- tests/unit/images/test_client.py | 3 ++- tests/unit/server_types/test_client.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 744b13bb..cf6ff2f9 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -216,7 +216,8 @@ def test_get_all(self, images_client, two_images_response, params): def test_get_by_name(self, images_client, one_images_response): images_client._client.request.return_value = one_images_response - image = images_client.get_by_name("ubuntu-20.04") + with pytest.deprecated_call(): + image = images_client.get_by_name("ubuntu-20.04") params = {"name": "ubuntu-20.04"} diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 91d94e28..6021a563 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -35,7 +35,8 @@ def test_bound_server_type_init(self, server_type_response): assert bound_server_type.deprecation.unavailable_after == datetime( 2023, 9, 1, tzinfo=timezone.utc ) - assert bound_server_type.included_traffic == 21990232555520 + with pytest.deprecated_call(): + assert bound_server_type.included_traffic == 21990232555520 class TestServerTypesClient: From 0a95f43ebc192a2b5aac716fd236a4d5f944d215 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 14:46:37 +0200 Subject: [PATCH 073/213] chore(deps): update apricote/releaser-pleaser action to v0.6.1 (#517) 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 | |---|---|---|---| | [apricote/releaser-pleaser](https://redirect.github.com/apricote/releaser-pleaser) | action | patch | `v0.6.0` -> `v0.6.1` | --- ### Release Notes
apricote/releaser-pleaser (apricote/releaser-pleaser) ### [`v0.6.1`](https://redirect.github.com/apricote/releaser-pleaser/blob/HEAD/CHANGELOG.md#v061) [Compare Source](https://redirect.github.com/apricote/releaser-pleaser/compare/v0.6.0...v0.6.1) ##### Bug Fixes - **gitlab**: support fast-forward merges ([#​210](https://redirect.github.com/apricote/releaser-pleaser/issues/210))
--- ### 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> --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 313182ed..7ad3ec9c 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.6.0 + uses: apricote/releaser-pleaser@v0.6.1 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From d62bb5f4ebef6af71b5e2bb4eff98201ec56b64e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:48:30 +0200 Subject: [PATCH 074/213] chore(deps): update dependency mypy to >=1.17,<1.18 (#518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [mypy](https://redirect.github.com/python/mypy) ([changelog](https://mypy.readthedocs.io/en/latest/changelog.html)) | `>=1.16,<1.17` -> `>=1.17,<1.18` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/mypy/1.17.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/mypy/1.16.1/1.17.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
python/mypy (mypy) ### [`v1.17.0`](https://redirect.github.com/python/mypy/compare/v1.16.1...v1.17.0) [Compare Source](https://redirect.github.com/python/mypy/compare/v1.16.1...v1.17.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c3b6be63..b8c9403c 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.5", "pytest-cov>=6,<6.3", - "mypy>=1.16,<1.17", + "mypy>=1.17,<1.18", "types-python-dateutil", "types-requests", ], From be61f7f14b4714a7a0a6db8e8328e020872d2a25 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 17 Jul 2025 14:29:04 +0200 Subject: [PATCH 075/213] style: format changelog (#521) --- .pre-commit-config.yaml | 1 - CHANGELOG.md | 371 +++++++++++++++++----------------------- 2 files changed, 156 insertions(+), 216 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4aedcd34..279d7ab7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,7 +27,6 @@ repos: hooks: - id: prettier files: \.(md|ya?ml|js|css)$ - exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade rev: v3.20.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd0d529..7da24530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,50 +46,43 @@ ## [2.3.0](https://github.com/hetznercloud/hcloud-python/compare/v2.2.1...v2.3.0) (2024-10-09) - ### Features -* support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) - +- support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) ### Bug Fixes -* change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) +- change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) ## [2.2.1](https://github.com/hetznercloud/hcloud-python/compare/v2.2.0...v2.2.1) (2024-08-19) - ### Bug Fixes -* prices properties are list of dict ([#438](https://github.com/hetznercloud/hcloud-python/issues/438)) ([9621604](https://github.com/hetznercloud/hcloud-python/commit/96216048c9ba13b6286d584c2dd0ec440f484105)), closes [#437](https://github.com/hetznercloud/hcloud-python/issues/437) +- prices properties are list of dict ([#438](https://github.com/hetznercloud/hcloud-python/issues/438)) ([9621604](https://github.com/hetznercloud/hcloud-python/commit/96216048c9ba13b6286d584c2dd0ec440f484105)), closes [#437](https://github.com/hetznercloud/hcloud-python/issues/437) ## [2.2.0](https://github.com/hetznercloud/hcloud-python/compare/v2.1.1...v2.2.0) (2024-08-06) - ### Features -* retry requests when the api gateway errors ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) -* retry requests when the api returns a conflict error ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) -* retry requests when the network timed outs ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) -* retry requests when the rate limit was reached ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) - +- retry requests when the api gateway errors ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the api returns a conflict error ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the network timed outs ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the rate limit was reached ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) ### Bug Fixes -* update network subnet types ([#431](https://github.com/hetznercloud/hcloud-python/issues/431)) ([c32a615](https://github.com/hetznercloud/hcloud-python/commit/c32a615db778d57324632d8df99356bb04a91efa)) +- update network subnet types ([#431](https://github.com/hetznercloud/hcloud-python/issues/431)) ([c32a615](https://github.com/hetznercloud/hcloud-python/commit/c32a615db778d57324632d8df99356bb04a91efa)) ## [2.1.1](https://github.com/hetznercloud/hcloud-python/compare/v2.1.0...v2.1.1) (2024-07-30) - ### Bug Fixes -* do not sleep before checking for the reloaded action status ([#426](https://github.com/hetznercloud/hcloud-python/issues/426)) ([3e0a85b](https://github.com/hetznercloud/hcloud-python/commit/3e0a85b487fc15941008e4d610243de3cb0396cb)) -* mark client retry backoff function as static ([#429](https://github.com/hetznercloud/hcloud-python/issues/429)) ([14ed130](https://github.com/hetznercloud/hcloud-python/commit/14ed130e989c68eacce2634c7983b200570de9c2)) - +- do not sleep before checking for the reloaded action status ([#426](https://github.com/hetznercloud/hcloud-python/issues/426)) ([3e0a85b](https://github.com/hetznercloud/hcloud-python/commit/3e0a85b487fc15941008e4d610243de3cb0396cb)) +- mark client retry backoff function as static ([#429](https://github.com/hetznercloud/hcloud-python/issues/429)) ([14ed130](https://github.com/hetznercloud/hcloud-python/commit/14ed130e989c68eacce2634c7983b200570de9c2)) ### Documentation -* add api changes note in changelog ([#424](https://github.com/hetznercloud/hcloud-python/issues/424)) ([5cbe188](https://github.com/hetznercloud/hcloud-python/commit/5cbe1889a21c686588d91ab90306d345ba5b84dd)) +- add api changes note in changelog ([#424](https://github.com/hetznercloud/hcloud-python/issues/424)) ([5cbe188](https://github.com/hetznercloud/hcloud-python/commit/5cbe1889a21c686588d91ab90306d345ba5b84dd)) ## [2.1.0](https://github.com/hetznercloud/hcloud-python/compare/v2.0.1...v2.1.0) (2024-07-25) @@ -101,408 +94,356 @@ You can learn more about this change in [our changelog](https://docs.hetzner.clo ### Features -* add exponential and constant backoff function ([#416](https://github.com/hetznercloud/hcloud-python/issues/416)) ([fe7ddf6](https://github.com/hetznercloud/hcloud-python/commit/fe7ddf6da78f8dbbc395eb98ff1200b8117f0cc0)) -* deprecate `ServerType` `included_traffic` property ([#423](https://github.com/hetznercloud/hcloud-python/issues/423)) ([3d56ac5](https://github.com/hetznercloud/hcloud-python/commit/3d56ac57d092bb30543fac9249c04393d0864c3b)) -* use exponential backoff when retrying requests ([#417](https://github.com/hetznercloud/hcloud-python/issues/417)) ([f306073](https://github.com/hetznercloud/hcloud-python/commit/f3060737d0e2991a0abf69e4953a3967ac8f84ed)) +- add exponential and constant backoff function ([#416](https://github.com/hetznercloud/hcloud-python/issues/416)) ([fe7ddf6](https://github.com/hetznercloud/hcloud-python/commit/fe7ddf6da78f8dbbc395eb98ff1200b8117f0cc0)) +- deprecate `ServerType` `included_traffic` property ([#423](https://github.com/hetznercloud/hcloud-python/issues/423)) ([3d56ac5](https://github.com/hetznercloud/hcloud-python/commit/3d56ac57d092bb30543fac9249c04393d0864c3b)) +- use exponential backoff when retrying requests ([#417](https://github.com/hetznercloud/hcloud-python/issues/417)) ([f306073](https://github.com/hetznercloud/hcloud-python/commit/f3060737d0e2991a0abf69e4953a3967ac8f84ed)) ## [2.0.1](https://github.com/hetznercloud/hcloud-python/compare/v2.0.0...v2.0.1) (2024-07-03) - ### Bug Fixes -* `assignee_type` is required when creating a primary ip ([#409](https://github.com/hetznercloud/hcloud-python/issues/409)) ([bce5e94](https://github.com/hetznercloud/hcloud-python/commit/bce5e940e27f2c6d9d50016b5828c79aadfc4401)) -* clean unused arguments in the `Client.servers.rebuild` method ([#407](https://github.com/hetznercloud/hcloud-python/issues/407)) ([6d33c3c](https://github.com/hetznercloud/hcloud-python/commit/6d33c3cff5443686c7ed37eb8635e0461bb3b928)) -* details are optional in API errors ([#411](https://github.com/hetznercloud/hcloud-python/issues/411)) ([f1c6594](https://github.com/hetznercloud/hcloud-python/commit/f1c6594dee7088872f2375359ee259e4e93b31d2)) -* rename `trace_id` variable to `correlation_id` ([#408](https://github.com/hetznercloud/hcloud-python/issues/408)) ([66a0f54](https://github.com/hetznercloud/hcloud-python/commit/66a0f546998193f9078f70a4a2fb1fc11937c086)) +- `assignee_type` is required when creating a primary ip ([#409](https://github.com/hetznercloud/hcloud-python/issues/409)) ([bce5e94](https://github.com/hetznercloud/hcloud-python/commit/bce5e940e27f2c6d9d50016b5828c79aadfc4401)) +- clean unused arguments in the `Client.servers.rebuild` method ([#407](https://github.com/hetznercloud/hcloud-python/issues/407)) ([6d33c3c](https://github.com/hetznercloud/hcloud-python/commit/6d33c3cff5443686c7ed37eb8635e0461bb3b928)) +- details are optional in API errors ([#411](https://github.com/hetznercloud/hcloud-python/issues/411)) ([f1c6594](https://github.com/hetznercloud/hcloud-python/commit/f1c6594dee7088872f2375359ee259e4e93b31d2)) +- rename `trace_id` variable to `correlation_id` ([#408](https://github.com/hetznercloud/hcloud-python/issues/408)) ([66a0f54](https://github.com/hetznercloud/hcloud-python/commit/66a0f546998193f9078f70a4a2fb1fc11937c086)) ## [2.0.0](https://github.com/hetznercloud/hcloud-python/compare/v1.35.0...v2.0.0) (2024-07-03) - ### ⚠ BREAKING CHANGES -* return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) -* make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) -* remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) -* make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) -* make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) -* return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) -* remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) -* move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) +- make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ### Features -* add `trace_id` to API exceptions ([#404](https://github.com/hetznercloud/hcloud-python/issues/404)) ([8375261](https://github.com/hetznercloud/hcloud-python/commit/8375261da3b84d6fece97263c7bea40ad2a6cfcf)) -* allow using a custom poll_interval function ([#403](https://github.com/hetznercloud/hcloud-python/issues/403)) ([93eb56b](https://github.com/hetznercloud/hcloud-python/commit/93eb56ba4d1a69e175398bca42e723a7e8e46371)) -* make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) ([d5f24db](https://github.com/hetznercloud/hcloud-python/commit/d5f24db2816a0d00b8c7936e2a0290d2c4bb1e92)) -* make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) ([428ea7e](https://github.com/hetznercloud/hcloud-python/commit/428ea7e3be03a16114f875146971db59aabaac2c)) -* move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ([4e3f638](https://github.com/hetznercloud/hcloud-python/commit/4e3f638862c9d260df98182c3f7858282049c26c)), closes [#234](https://github.com/hetznercloud/hcloud-python/issues/234) -* remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) ([db37e63](https://github.com/hetznercloud/hcloud-python/commit/db37e633ebbf73354d3b2f4858cf3eebf173bfbc)) -* remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) ([6b977e2](https://github.com/hetznercloud/hcloud-python/commit/6b977e2da5cec30110c32a91d572003e5b5c400a)) -* return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) ([9f46adb](https://github.com/hetznercloud/hcloud-python/commit/9f46adb946eb2770ee4f3a4e87cfc1c8b9b33c28)) -* return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) ([1970d84](https://github.com/hetznercloud/hcloud-python/commit/1970d84bec2106c8c53d8e611b74d41eb5286e9b)) - +- add `trace_id` to API exceptions ([#404](https://github.com/hetznercloud/hcloud-python/issues/404)) ([8375261](https://github.com/hetznercloud/hcloud-python/commit/8375261da3b84d6fece97263c7bea40ad2a6cfcf)) +- allow using a custom poll_interval function ([#403](https://github.com/hetznercloud/hcloud-python/issues/403)) ([93eb56b](https://github.com/hetznercloud/hcloud-python/commit/93eb56ba4d1a69e175398bca42e723a7e8e46371)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) ([d5f24db](https://github.com/hetznercloud/hcloud-python/commit/d5f24db2816a0d00b8c7936e2a0290d2c4bb1e92)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) ([428ea7e](https://github.com/hetznercloud/hcloud-python/commit/428ea7e3be03a16114f875146971db59aabaac2c)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ([4e3f638](https://github.com/hetznercloud/hcloud-python/commit/4e3f638862c9d260df98182c3f7858282049c26c)), closes [#234](https://github.com/hetznercloud/hcloud-python/issues/234) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) ([db37e63](https://github.com/hetznercloud/hcloud-python/commit/db37e633ebbf73354d3b2f4858cf3eebf173bfbc)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) ([6b977e2](https://github.com/hetznercloud/hcloud-python/commit/6b977e2da5cec30110c32a91d572003e5b5c400a)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) ([9f46adb](https://github.com/hetznercloud/hcloud-python/commit/9f46adb946eb2770ee4f3a4e87cfc1c8b9b33c28)) +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) ([1970d84](https://github.com/hetznercloud/hcloud-python/commit/1970d84bec2106c8c53d8e611b74d41eb5286e9b)) ### Bug Fixes -* make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) ([ebef774](https://github.com/hetznercloud/hcloud-python/commit/ebef77464c4c3b0ce33460cad2747e89d35047c7)) - +- make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) ([ebef774](https://github.com/hetznercloud/hcloud-python/commit/ebef77464c4c3b0ce33460cad2747e89d35047c7)) ### Dependencies -* update dependency coverage to >=7.5,<7.6 ([#386](https://github.com/hetznercloud/hcloud-python/issues/386)) ([5660691](https://github.com/hetznercloud/hcloud-python/commit/5660691ebd6122fa7ebec56a24bce9fce0577573)) -* update dependency mypy to >=1.10,<1.11 ([#387](https://github.com/hetznercloud/hcloud-python/issues/387)) ([35c933b](https://github.com/hetznercloud/hcloud-python/commit/35c933bd2108d42e74b74b01d6db74e159ec9142)) -* update dependency myst-parser to v3 ([#385](https://github.com/hetznercloud/hcloud-python/issues/385)) ([9f18270](https://github.com/hetznercloud/hcloud-python/commit/9f182704898cb96f1ea162511605906f87cff50c)) -* update dependency pylint to >=3,<3.3 ([#391](https://github.com/hetznercloud/hcloud-python/issues/391)) ([4a6f005](https://github.com/hetznercloud/hcloud-python/commit/4a6f005cb0488291ae91390a612bab6afc6d80b6)) -* update dependency pytest to >=8,<8.3 ([#390](https://github.com/hetznercloud/hcloud-python/issues/390)) ([584a36b](https://github.com/hetznercloud/hcloud-python/commit/584a36b658670297ffffa9afa70835d29d27fbca)) -* update dependency sphinx to >=7.3.4,<7.4 ([#383](https://github.com/hetznercloud/hcloud-python/issues/383)) ([69c2e16](https://github.com/hetznercloud/hcloud-python/commit/69c2e16073df9ef8520e3a635b3866403eba030e)) -* update pre-commit hook asottile/pyupgrade to v3.16.0 ([0ce5fbc](https://github.com/hetznercloud/hcloud-python/commit/0ce5fbccba4a4255e08a37abf1f21ab9cc85f287)) -* update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 ([5ef25ab](https://github.com/hetznercloud/hcloud-python/commit/5ef25ab3966d731c4c36ea3e785c2b5f20c69489)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.4.0 ([0941fbf](https://github.com/hetznercloud/hcloud-python/commit/0941fbfab20ca8a59e768c4a5e6fc101393c97f0)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.4.1 ([fec08c5](https://github.com/hetznercloud/hcloud-python/commit/fec08c5323359d0a4f0771123f483ff975aa68b0)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.4.2 ([#389](https://github.com/hetznercloud/hcloud-python/issues/389)) ([2b2e21f](https://github.com/hetznercloud/hcloud-python/commit/2b2e21f61366b5ec0f2ff5558f652d2bfed9d138)) -* update pre-commit hook pycqa/flake8 to v7.1.0 ([3bc651d](https://github.com/hetznercloud/hcloud-python/commit/3bc651d50d85aa92ba76dbfeef1d604cabaa4628)) - +- update dependency coverage to >=7.5,<7.6 ([#386](https://github.com/hetznercloud/hcloud-python/issues/386)) ([5660691](https://github.com/hetznercloud/hcloud-python/commit/5660691ebd6122fa7ebec56a24bce9fce0577573)) +- update dependency mypy to >=1.10,<1.11 ([#387](https://github.com/hetznercloud/hcloud-python/issues/387)) ([35c933b](https://github.com/hetznercloud/hcloud-python/commit/35c933bd2108d42e74b74b01d6db74e159ec9142)) +- update dependency myst-parser to v3 ([#385](https://github.com/hetznercloud/hcloud-python/issues/385)) ([9f18270](https://github.com/hetznercloud/hcloud-python/commit/9f182704898cb96f1ea162511605906f87cff50c)) +- update dependency pylint to >=3,<3.3 ([#391](https://github.com/hetznercloud/hcloud-python/issues/391)) ([4a6f005](https://github.com/hetznercloud/hcloud-python/commit/4a6f005cb0488291ae91390a612bab6afc6d80b6)) +- update dependency pytest to >=8,<8.3 ([#390](https://github.com/hetznercloud/hcloud-python/issues/390)) ([584a36b](https://github.com/hetznercloud/hcloud-python/commit/584a36b658670297ffffa9afa70835d29d27fbca)) +- update dependency sphinx to >=7.3.4,<7.4 ([#383](https://github.com/hetznercloud/hcloud-python/issues/383)) ([69c2e16](https://github.com/hetznercloud/hcloud-python/commit/69c2e16073df9ef8520e3a635b3866403eba030e)) +- update pre-commit hook asottile/pyupgrade to v3.16.0 ([0ce5fbc](https://github.com/hetznercloud/hcloud-python/commit/0ce5fbccba4a4255e08a37abf1f21ab9cc85f287)) +- update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 ([5ef25ab](https://github.com/hetznercloud/hcloud-python/commit/5ef25ab3966d731c4c36ea3e785c2b5f20c69489)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.0 ([0941fbf](https://github.com/hetznercloud/hcloud-python/commit/0941fbfab20ca8a59e768c4a5e6fc101393c97f0)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.1 ([fec08c5](https://github.com/hetznercloud/hcloud-python/commit/fec08c5323359d0a4f0771123f483ff975aa68b0)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.2 ([#389](https://github.com/hetznercloud/hcloud-python/issues/389)) ([2b2e21f](https://github.com/hetznercloud/hcloud-python/commit/2b2e21f61366b5ec0f2ff5558f652d2bfed9d138)) +- update pre-commit hook pycqa/flake8 to v7.1.0 ([3bc651d](https://github.com/hetznercloud/hcloud-python/commit/3bc651d50d85aa92ba76dbfeef1d604cabaa4628)) ### Documentation -* add v2 upgrade notes ([#405](https://github.com/hetznercloud/hcloud-python/issues/405)) ([c77f771](https://github.com/hetznercloud/hcloud-python/commit/c77f771e2bed176acd6aa5011be006c800181809)) -* cx11 is name, not an id ([#381](https://github.com/hetznercloud/hcloud-python/issues/381)) ([b745d40](https://github.com/hetznercloud/hcloud-python/commit/b745d4049f720b93d840a9204a99d246ecb499e5)) +- add v2 upgrade notes ([#405](https://github.com/hetznercloud/hcloud-python/issues/405)) ([c77f771](https://github.com/hetznercloud/hcloud-python/commit/c77f771e2bed176acd6aa5011be006c800181809)) +- cx11 is name, not an id ([#381](https://github.com/hetznercloud/hcloud-python/issues/381)) ([b745d40](https://github.com/hetznercloud/hcloud-python/commit/b745d4049f720b93d840a9204a99d246ecb499e5)) ## [1.35.0](https://github.com/hetznercloud/hcloud-python/compare/v1.34.0...v1.35.0) (2024-04-02) - ### Features -* add `include_deprecated` option when fetching images by name ([#375](https://github.com/hetznercloud/hcloud-python/issues/375)) ([6d86f86](https://github.com/hetznercloud/hcloud-python/commit/6d86f86677fec23e6fd8a69d20d787e234e0fb53)) - +- add `include_deprecated` option when fetching images by name ([#375](https://github.com/hetznercloud/hcloud-python/issues/375)) ([6d86f86](https://github.com/hetznercloud/hcloud-python/commit/6d86f86677fec23e6fd8a69d20d787e234e0fb53)) ### Bug Fixes -* raise warnings for the `ImagesClient.get_by_name` deprecation ([#376](https://github.com/hetznercloud/hcloud-python/issues/376)) ([b24de80](https://github.com/hetznercloud/hcloud-python/commit/b24de80684db142ebbe11b62a38d9c61f248e216)) +- raise warnings for the `ImagesClient.get_by_name` deprecation ([#376](https://github.com/hetznercloud/hcloud-python/issues/376)) ([b24de80](https://github.com/hetznercloud/hcloud-python/commit/b24de80684db142ebbe11b62a38d9c61f248e216)) ## [1.34.0](https://github.com/hetznercloud/hcloud-python/compare/v1.33.3...v1.34.0) (2024-03-27) - ### Features -* add `has_id_or_name` to `DomainIdentityMixin` ([#373](https://github.com/hetznercloud/hcloud-python/issues/373)) ([8facaf6](https://github.com/hetznercloud/hcloud-python/commit/8facaf6d4dd2bbfb4137e7066b49c5f4c1db773c)) +- add `has_id_or_name` to `DomainIdentityMixin` ([#373](https://github.com/hetznercloud/hcloud-python/issues/373)) ([8facaf6](https://github.com/hetznercloud/hcloud-python/commit/8facaf6d4dd2bbfb4137e7066b49c5f4c1db773c)) ## [1.33.3](https://github.com/hetznercloud/hcloud-python/compare/v1.33.2...v1.33.3) (2024-03-27) - ### Bug Fixes -* invalid type for load balancer private network property ([#372](https://github.com/hetznercloud/hcloud-python/issues/372)) ([903e92f](https://github.com/hetznercloud/hcloud-python/commit/903e92faab745b7f8270f6195da67f4d9f8b1ba7)) - +- invalid type for load balancer private network property ([#372](https://github.com/hetznercloud/hcloud-python/issues/372)) ([903e92f](https://github.com/hetznercloud/hcloud-python/commit/903e92faab745b7f8270f6195da67f4d9f8b1ba7)) ### Dependencies -* update codecov/codecov-action action to v4 ([#359](https://github.com/hetznercloud/hcloud-python/issues/359)) ([a798979](https://github.com/hetznercloud/hcloud-python/commit/a79897977abe970181d19584e51448ff5976b5e2)) -* update dependency mypy to >=1.9,<1.10 ([#368](https://github.com/hetznercloud/hcloud-python/issues/368)) ([4b9328c](https://github.com/hetznercloud/hcloud-python/commit/4b9328ceae1e393ff55b3ca6f030cb5ac565be00)) -* update dependency pylint to >=3,<3.2 ([#364](https://github.com/hetznercloud/hcloud-python/issues/364)) ([d71d17f](https://github.com/hetznercloud/hcloud-python/commit/d71d17fd6f2968a8c19052753265ef7f514a8955)) -* update dependency pytest to >=8,<8.2 ([#366](https://github.com/hetznercloud/hcloud-python/issues/366)) ([8665dcf](https://github.com/hetznercloud/hcloud-python/commit/8665dcff335c755c1ff4d95621334a3f5e196d34)) -* update dependency pytest to v8 ([#357](https://github.com/hetznercloud/hcloud-python/issues/357)) ([f8f756f](https://github.com/hetznercloud/hcloud-python/commit/f8f756fe0a492e284bd2a700514c0ba38358b4a8)) -* update dependency pytest-cov to v5 ([#371](https://github.com/hetznercloud/hcloud-python/issues/371)) ([04a6a42](https://github.com/hetznercloud/hcloud-python/commit/04a6a42028606ed66657605d98b1f21545eb2e0d)) -* update dependency watchdog to v4 ([#360](https://github.com/hetznercloud/hcloud-python/issues/360)) ([cb8d383](https://github.com/hetznercloud/hcloud-python/commit/cb8d38396a8665506e3be64a09450343d7671586)) -* update pre-commit hook asottile/pyupgrade to v3.15.1 ([#362](https://github.com/hetznercloud/hcloud-python/issues/362)) ([dd2a521](https://github.com/hetznercloud/hcloud-python/commit/dd2a521eccec8e15b6d1d7fd843d866bf6ea5bcf)) -* update pre-commit hook asottile/pyupgrade to v3.15.2 ([3d02ad7](https://github.com/hetznercloud/hcloud-python/commit/3d02ad71e9200f5cc94b2d33eea62035edc1e33a)) -* update pre-commit hook psf/black-pre-commit-mirror to v24 ([#356](https://github.com/hetznercloud/hcloud-python/issues/356)) ([b46397d](https://github.com/hetznercloud/hcloud-python/commit/b46397d761caa60014bd32f7142b79bef9a92e18)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.1.1 ([#358](https://github.com/hetznercloud/hcloud-python/issues/358)) ([7e4645e](https://github.com/hetznercloud/hcloud-python/commit/7e4645e3e38a106f38a7f63810d71a628fead939)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.2.0 ([#361](https://github.com/hetznercloud/hcloud-python/issues/361)) ([5b56ace](https://github.com/hetznercloud/hcloud-python/commit/5b56ace93b8b4fddddbf5610c11fd20bf6f9a561)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.3.0 ([3bbac5d](https://github.com/hetznercloud/hcloud-python/commit/3bbac5dc41ca509d6679fd6b06ae99ca33fd62ee)) -* update pre-commit hook pycqa/flake8 to v7 ([#354](https://github.com/hetznercloud/hcloud-python/issues/354)) ([66a582f](https://github.com/hetznercloud/hcloud-python/commit/66a582f3ce728d92045625885d0634fc96fbc6a0)) -* update pypa/gh-action-pypi-publish action to v1.8.12 ([#365](https://github.com/hetznercloud/hcloud-python/issues/365)) ([55db255](https://github.com/hetznercloud/hcloud-python/commit/55db2551dd0f0ea6a29da4e7a6dce2af8de86eaf)) -* update pypa/gh-action-pypi-publish action to v1.8.14 ([#367](https://github.com/hetznercloud/hcloud-python/issues/367)) ([0cb615f](https://github.com/hetznercloud/hcloud-python/commit/0cb615fe0d852cddbf636c1fdb8538ad60f5a3d9)) +- update codecov/codecov-action action to v4 ([#359](https://github.com/hetznercloud/hcloud-python/issues/359)) ([a798979](https://github.com/hetznercloud/hcloud-python/commit/a79897977abe970181d19584e51448ff5976b5e2)) +- update dependency mypy to >=1.9,<1.10 ([#368](https://github.com/hetznercloud/hcloud-python/issues/368)) ([4b9328c](https://github.com/hetznercloud/hcloud-python/commit/4b9328ceae1e393ff55b3ca6f030cb5ac565be00)) +- update dependency pylint to >=3,<3.2 ([#364](https://github.com/hetznercloud/hcloud-python/issues/364)) ([d71d17f](https://github.com/hetznercloud/hcloud-python/commit/d71d17fd6f2968a8c19052753265ef7f514a8955)) +- update dependency pytest to >=8,<8.2 ([#366](https://github.com/hetznercloud/hcloud-python/issues/366)) ([8665dcf](https://github.com/hetznercloud/hcloud-python/commit/8665dcff335c755c1ff4d95621334a3f5e196d34)) +- update dependency pytest to v8 ([#357](https://github.com/hetznercloud/hcloud-python/issues/357)) ([f8f756f](https://github.com/hetznercloud/hcloud-python/commit/f8f756fe0a492e284bd2a700514c0ba38358b4a8)) +- update dependency pytest-cov to v5 ([#371](https://github.com/hetznercloud/hcloud-python/issues/371)) ([04a6a42](https://github.com/hetznercloud/hcloud-python/commit/04a6a42028606ed66657605d98b1f21545eb2e0d)) +- update dependency watchdog to v4 ([#360](https://github.com/hetznercloud/hcloud-python/issues/360)) ([cb8d383](https://github.com/hetznercloud/hcloud-python/commit/cb8d38396a8665506e3be64a09450343d7671586)) +- update pre-commit hook asottile/pyupgrade to v3.15.1 ([#362](https://github.com/hetznercloud/hcloud-python/issues/362)) ([dd2a521](https://github.com/hetznercloud/hcloud-python/commit/dd2a521eccec8e15b6d1d7fd843d866bf6ea5bcf)) +- update pre-commit hook asottile/pyupgrade to v3.15.2 ([3d02ad7](https://github.com/hetznercloud/hcloud-python/commit/3d02ad71e9200f5cc94b2d33eea62035edc1e33a)) +- update pre-commit hook psf/black-pre-commit-mirror to v24 ([#356](https://github.com/hetznercloud/hcloud-python/issues/356)) ([b46397d](https://github.com/hetznercloud/hcloud-python/commit/b46397d761caa60014bd32f7142b79bef9a92e18)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.1.1 ([#358](https://github.com/hetznercloud/hcloud-python/issues/358)) ([7e4645e](https://github.com/hetznercloud/hcloud-python/commit/7e4645e3e38a106f38a7f63810d71a628fead939)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.2.0 ([#361](https://github.com/hetznercloud/hcloud-python/issues/361)) ([5b56ace](https://github.com/hetznercloud/hcloud-python/commit/5b56ace93b8b4fddddbf5610c11fd20bf6f9a561)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.3.0 ([3bbac5d](https://github.com/hetznercloud/hcloud-python/commit/3bbac5dc41ca509d6679fd6b06ae99ca33fd62ee)) +- update pre-commit hook pycqa/flake8 to v7 ([#354](https://github.com/hetznercloud/hcloud-python/issues/354)) ([66a582f](https://github.com/hetznercloud/hcloud-python/commit/66a582f3ce728d92045625885d0634fc96fbc6a0)) +- update pypa/gh-action-pypi-publish action to v1.8.12 ([#365](https://github.com/hetznercloud/hcloud-python/issues/365)) ([55db255](https://github.com/hetznercloud/hcloud-python/commit/55db2551dd0f0ea6a29da4e7a6dce2af8de86eaf)) +- update pypa/gh-action-pypi-publish action to v1.8.14 ([#367](https://github.com/hetznercloud/hcloud-python/issues/367)) ([0cb615f](https://github.com/hetznercloud/hcloud-python/commit/0cb615fe0d852cddbf636c1fdb8538ad60f5a3d9)) ## [1.33.2](https://github.com/hetznercloud/hcloud-python/compare/v1.33.1...v1.33.2) (2024-01-02) - ### Bug Fixes -* publish package to PyPI using OIDC auth ([1a0e93b](https://github.com/hetznercloud/hcloud-python/commit/1a0e93bbf1ae6cc747e6c4d8305dafd3e49dbbdc)) +- publish package to PyPI using OIDC auth ([1a0e93b](https://github.com/hetznercloud/hcloud-python/commit/1a0e93bbf1ae6cc747e6c4d8305dafd3e49dbbdc)) ## [1.33.1](https://github.com/hetznercloud/hcloud-python/compare/v1.33.0...v1.33.1) (2024-01-02) - ### Bug Fixes -* private object not exported in top level module ([#346](https://github.com/hetznercloud/hcloud-python/issues/346)) ([5281b05](https://github.com/hetznercloud/hcloud-python/commit/5281b0583541b6e0e9b8c7ad75faa42c5d379735)) - +- private object not exported in top level module ([#346](https://github.com/hetznercloud/hcloud-python/issues/346)) ([5281b05](https://github.com/hetznercloud/hcloud-python/commit/5281b0583541b6e0e9b8c7ad75faa42c5d379735)) ### Dependencies -* update dependency coverage to >=7.4,<7.5 ([#348](https://github.com/hetznercloud/hcloud-python/issues/348)) ([3ac5711](https://github.com/hetznercloud/hcloud-python/commit/3ac57117e8a68a02cba19c56f850f037c4aca462)) -* update dependency mypy to >=1.8,<1.9 ([#343](https://github.com/hetznercloud/hcloud-python/issues/343)) ([984022f](https://github.com/hetznercloud/hcloud-python/commit/984022fd3888ef856be83de82554d55a8af18dba)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.12.1 ([#347](https://github.com/hetznercloud/hcloud-python/issues/347)) ([2c24efe](https://github.com/hetznercloud/hcloud-python/commit/2c24efe93bc221846f8dcc91abcf1aad61547875)) +- update dependency coverage to >=7.4,<7.5 ([#348](https://github.com/hetznercloud/hcloud-python/issues/348)) ([3ac5711](https://github.com/hetznercloud/hcloud-python/commit/3ac57117e8a68a02cba19c56f850f037c4aca462)) +- update dependency mypy to >=1.8,<1.9 ([#343](https://github.com/hetznercloud/hcloud-python/issues/343)) ([984022f](https://github.com/hetznercloud/hcloud-python/commit/984022fd3888ef856be83de82554d55a8af18dba)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.1 ([#347](https://github.com/hetznercloud/hcloud-python/issues/347)) ([2c24efe](https://github.com/hetznercloud/hcloud-python/commit/2c24efe93bc221846f8dcc91abcf1aad61547875)) ## [1.33.0](https://github.com/hetznercloud/hcloud-python/compare/v1.32.0...v1.33.0) (2023-12-19) - ### Features -* add metrics endpoint for load balancers and servers ([#331](https://github.com/hetznercloud/hcloud-python/issues/331)) ([ee3c54f](https://github.com/hetznercloud/hcloud-python/commit/ee3c54fd1b6963533bc9d1e1f9ff57f6c5872cd5)) - +- add metrics endpoint for load balancers and servers ([#331](https://github.com/hetznercloud/hcloud-python/issues/331)) ([ee3c54f](https://github.com/hetznercloud/hcloud-python/commit/ee3c54fd1b6963533bc9d1e1f9ff57f6c5872cd5)) ### Bug Fixes -* fallback to error code when message is unset ([#328](https://github.com/hetznercloud/hcloud-python/issues/328)) ([1c94153](https://github.com/hetznercloud/hcloud-python/commit/1c94153d93acd567548604b08b5fabeabd8d33d9)) - +- fallback to error code when message is unset ([#328](https://github.com/hetznercloud/hcloud-python/issues/328)) ([1c94153](https://github.com/hetznercloud/hcloud-python/commit/1c94153d93acd567548604b08b5fabeabd8d33d9)) ### Dependencies -* update actions/setup-python action to v5 ([#335](https://github.com/hetznercloud/hcloud-python/issues/335)) ([2ac252d](https://github.com/hetznercloud/hcloud-python/commit/2ac252d18ba6079d5372c6ab9e3f67b4740db465)) -* update dependency sphinx-rtd-theme to v2 ([#330](https://github.com/hetznercloud/hcloud-python/issues/330)) ([7cc4335](https://github.com/hetznercloud/hcloud-python/commit/7cc4335cacab6073cf39a0ecbecf8890903d5bca)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.12.0 ([#338](https://github.com/hetznercloud/hcloud-python/issues/338)) ([38e4748](https://github.com/hetznercloud/hcloud-python/commit/38e4748d3d194d37ea3d0c63683609f5db432e0d)) -* update pre-commit hook pycqa/isort to v5.13.0 ([#336](https://github.com/hetznercloud/hcloud-python/issues/336)) ([3244cfe](https://github.com/hetznercloud/hcloud-python/commit/3244cfef2f90ef52d0fb791d514d6afe481aa4d7)) -* update pre-commit hook pycqa/isort to v5.13.1 ([#337](https://github.com/hetznercloud/hcloud-python/issues/337)) ([020a0ef](https://github.com/hetznercloud/hcloud-python/commit/020a0eff6bc2b63d16b339fd5d4c3ea3610c0509)) -* update pre-commit hook pycqa/isort to v5.13.2 ([#339](https://github.com/hetznercloud/hcloud-python/issues/339)) ([b46df8c](https://github.com/hetznercloud/hcloud-python/commit/b46df8cbb263945c59ce4408e0a7189d19d9c597)) +- update actions/setup-python action to v5 ([#335](https://github.com/hetznercloud/hcloud-python/issues/335)) ([2ac252d](https://github.com/hetznercloud/hcloud-python/commit/2ac252d18ba6079d5372c6ab9e3f67b4740db465)) +- update dependency sphinx-rtd-theme to v2 ([#330](https://github.com/hetznercloud/hcloud-python/issues/330)) ([7cc4335](https://github.com/hetznercloud/hcloud-python/commit/7cc4335cacab6073cf39a0ecbecf8890903d5bca)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.0 ([#338](https://github.com/hetznercloud/hcloud-python/issues/338)) ([38e4748](https://github.com/hetznercloud/hcloud-python/commit/38e4748d3d194d37ea3d0c63683609f5db432e0d)) +- update pre-commit hook pycqa/isort to v5.13.0 ([#336](https://github.com/hetznercloud/hcloud-python/issues/336)) ([3244cfe](https://github.com/hetznercloud/hcloud-python/commit/3244cfef2f90ef52d0fb791d514d6afe481aa4d7)) +- update pre-commit hook pycqa/isort to v5.13.1 ([#337](https://github.com/hetznercloud/hcloud-python/issues/337)) ([020a0ef](https://github.com/hetznercloud/hcloud-python/commit/020a0eff6bc2b63d16b339fd5d4c3ea3610c0509)) +- update pre-commit hook pycqa/isort to v5.13.2 ([#339](https://github.com/hetznercloud/hcloud-python/issues/339)) ([b46df8c](https://github.com/hetznercloud/hcloud-python/commit/b46df8cbb263945c59ce4408e0a7189d19d9c597)) ## [1.32.0](https://github.com/hetznercloud/hcloud-python/compare/v1.31.0...v1.32.0) (2023-11-17) - ### Features -* allow returning root_password in servers rebuild ([#276](https://github.com/hetznercloud/hcloud-python/issues/276)) ([38e098a](https://github.com/hetznercloud/hcloud-python/commit/38e098a41154e6561578cd723608fcd7577c3d01)) - +- allow returning root_password in servers rebuild ([#276](https://github.com/hetznercloud/hcloud-python/issues/276)) ([38e098a](https://github.com/hetznercloud/hcloud-python/commit/38e098a41154e6561578cd723608fcd7577c3d01)) ### Dependencies -* update dependency mypy to >=1.7,<1.8 ([#325](https://github.com/hetznercloud/hcloud-python/issues/325)) ([7b59a2d](https://github.com/hetznercloud/hcloud-python/commit/7b59a2decc9bb5152dc9de435bfe12ce1f34ac1c)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.1.0 ([#326](https://github.com/hetznercloud/hcloud-python/issues/326)) ([213b661](https://github.com/hetznercloud/hcloud-python/commit/213b661d897cdd327f478b52aeb79844826694d8)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.10.1 ([#322](https://github.com/hetznercloud/hcloud-python/issues/322)) ([999afe3](https://github.com/hetznercloud/hcloud-python/commit/999afe37e02a113639930aff6879f50918ac0e89)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.11.0 ([#324](https://github.com/hetznercloud/hcloud-python/issues/324)) ([7b2a24e](https://github.com/hetznercloud/hcloud-python/commit/7b2a24ecf69c0bead7f9113053fda37a0cc31d1b)) +- update dependency mypy to >=1.7,<1.8 ([#325](https://github.com/hetznercloud/hcloud-python/issues/325)) ([7b59a2d](https://github.com/hetznercloud/hcloud-python/commit/7b59a2decc9bb5152dc9de435bfe12ce1f34ac1c)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.1.0 ([#326](https://github.com/hetznercloud/hcloud-python/issues/326)) ([213b661](https://github.com/hetznercloud/hcloud-python/commit/213b661d897cdd327f478b52aeb79844826694d8)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.10.1 ([#322](https://github.com/hetznercloud/hcloud-python/issues/322)) ([999afe3](https://github.com/hetznercloud/hcloud-python/commit/999afe37e02a113639930aff6879f50918ac0e89)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.11.0 ([#324](https://github.com/hetznercloud/hcloud-python/issues/324)) ([7b2a24e](https://github.com/hetznercloud/hcloud-python/commit/7b2a24ecf69c0bead7f9113053fda37a0cc31d1b)) ## [1.31.0](https://github.com/hetznercloud/hcloud-python/compare/v1.30.0...v1.31.0) (2023-10-23) - ### Features -* prepare for iso deprecated field removal ([#320](https://github.com/hetznercloud/hcloud-python/issues/320)) ([beae328](https://github.com/hetznercloud/hcloud-python/commit/beae328dd6b9afb8c0db9fa9b44340270db7dd09)) - +- prepare for iso deprecated field removal ([#320](https://github.com/hetznercloud/hcloud-python/issues/320)) ([beae328](https://github.com/hetznercloud/hcloud-python/commit/beae328dd6b9afb8c0db9fa9b44340270db7dd09)) ### Dependencies -* update pre-commit hook psf/black-pre-commit-mirror to v23.10.0 ([#319](https://github.com/hetznercloud/hcloud-python/issues/319)) ([184bbe6](https://github.com/hetznercloud/hcloud-python/commit/184bbe65a736a42d13774b6c29fa7dd8a13ec645)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.10.0 ([#319](https://github.com/hetznercloud/hcloud-python/issues/319)) ([184bbe6](https://github.com/hetznercloud/hcloud-python/commit/184bbe65a736a42d13774b6c29fa7dd8a13ec645)) ## [1.30.0](https://github.com/hetznercloud/hcloud-python/compare/v1.29.1...v1.30.0) (2023-10-13) - ### Features -* add deprecation field to Iso ([#318](https://github.com/hetznercloud/hcloud-python/issues/318)) ([036b52f](https://github.com/hetznercloud/hcloud-python/commit/036b52fe51bcbb6b610c0c99ca224d3c4bbfc68d)) -* support python 3.12 ([#311](https://github.com/hetznercloud/hcloud-python/issues/311)) ([7e8cd1d](https://github.com/hetznercloud/hcloud-python/commit/7e8cd1d92e56d210fe3fb180e403122ef0e7bd7f)) - +- add deprecation field to Iso ([#318](https://github.com/hetznercloud/hcloud-python/issues/318)) ([036b52f](https://github.com/hetznercloud/hcloud-python/commit/036b52fe51bcbb6b610c0c99ca224d3c4bbfc68d)) +- support python 3.12 ([#311](https://github.com/hetznercloud/hcloud-python/issues/311)) ([7e8cd1d](https://github.com/hetznercloud/hcloud-python/commit/7e8cd1d92e56d210fe3fb180e403122ef0e7bd7f)) ### Dependencies -* update dependency mypy to >=1.6,<1.7 ([#317](https://github.com/hetznercloud/hcloud-python/issues/317)) ([d248bbd](https://github.com/hetznercloud/hcloud-python/commit/d248bbd4e55f3bcf6a107cfa4f38768df0bf3de5)) -* update dependency pylint to v3 ([#307](https://github.com/hetznercloud/hcloud-python/issues/307)) ([277841d](https://github.com/hetznercloud/hcloud-python/commit/277841dd84ba3b2bbc99a06a3f97e114d1c83dcb)) -* update pre-commit hook asottile/pyupgrade to v3.14.0 ([#308](https://github.com/hetznercloud/hcloud-python/issues/308)) ([07a4513](https://github.com/hetznercloud/hcloud-python/commit/07a4513e284b9ee964bca003d0a9dfd948d39b02)) -* update pre-commit hook asottile/pyupgrade to v3.15.0 ([#312](https://github.com/hetznercloud/hcloud-python/issues/312)) ([c544639](https://github.com/hetznercloud/hcloud-python/commit/c5446394acfa25d23761da4c6b5b75fb6d376b23)) -* update pre-commit hook pre-commit/pre-commit-hooks to v4.5.0 ([#313](https://github.com/hetznercloud/hcloud-python/issues/313)) ([e51eaa9](https://github.com/hetznercloud/hcloud-python/commit/e51eaa990336251c2afc8c83d4c5e6f5e5bb857b)) -* update python docker tag to v3.12 ([#309](https://github.com/hetznercloud/hcloud-python/issues/309)) ([3a1ee67](https://github.com/hetznercloud/hcloud-python/commit/3a1ee675f2c980a4d9e63188e8ffceb64f4797fc)) +- update dependency mypy to >=1.6,<1.7 ([#317](https://github.com/hetznercloud/hcloud-python/issues/317)) ([d248bbd](https://github.com/hetznercloud/hcloud-python/commit/d248bbd4e55f3bcf6a107cfa4f38768df0bf3de5)) +- update dependency pylint to v3 ([#307](https://github.com/hetznercloud/hcloud-python/issues/307)) ([277841d](https://github.com/hetznercloud/hcloud-python/commit/277841dd84ba3b2bbc99a06a3f97e114d1c83dcb)) +- update pre-commit hook asottile/pyupgrade to v3.14.0 ([#308](https://github.com/hetznercloud/hcloud-python/issues/308)) ([07a4513](https://github.com/hetznercloud/hcloud-python/commit/07a4513e284b9ee964bca003d0a9dfd948d39b02)) +- update pre-commit hook asottile/pyupgrade to v3.15.0 ([#312](https://github.com/hetznercloud/hcloud-python/issues/312)) ([c544639](https://github.com/hetznercloud/hcloud-python/commit/c5446394acfa25d23761da4c6b5b75fb6d376b23)) +- update pre-commit hook pre-commit/pre-commit-hooks to v4.5.0 ([#313](https://github.com/hetznercloud/hcloud-python/issues/313)) ([e51eaa9](https://github.com/hetznercloud/hcloud-python/commit/e51eaa990336251c2afc8c83d4c5e6f5e5bb857b)) +- update python docker tag to v3.12 ([#309](https://github.com/hetznercloud/hcloud-python/issues/309)) ([3a1ee67](https://github.com/hetznercloud/hcloud-python/commit/3a1ee675f2c980a4d9e63188e8ffceb64f4797fc)) ## [1.29.1](https://github.com/hetznercloud/hcloud-python/compare/v1.29.0...v1.29.1) (2023-09-26) - ### Bug Fixes -* prevent api calls when printing bound models ([#305](https://github.com/hetznercloud/hcloud-python/issues/305)) ([c1de7ef](https://github.com/hetznercloud/hcloud-python/commit/c1de7efc851b3b10e2a50e66268fc8fb0ff648a8)) +- prevent api calls when printing bound models ([#305](https://github.com/hetznercloud/hcloud-python/issues/305)) ([c1de7ef](https://github.com/hetznercloud/hcloud-python/commit/c1de7efc851b3b10e2a50e66268fc8fb0ff648a8)) ## [1.29.0](https://github.com/hetznercloud/hcloud-python/compare/v1.28.0...v1.29.0) (2023-09-25) - ### Features -* add domain attribute type hints to bound models ([#300](https://github.com/hetznercloud/hcloud-python/issues/300)) ([6d46d06](https://github.com/hetznercloud/hcloud-python/commit/6d46d06c42e2e86e88b32a74d7fbd588911cc8ad)) -* **firewalls:** add `applied_to_resources` to `FirewallResource` ([#297](https://github.com/hetznercloud/hcloud-python/issues/297)) ([55d2b20](https://github.com/hetznercloud/hcloud-python/commit/55d2b2043ec1e3a040eb9e360ca0dc0c299ad60f)) - +- add domain attribute type hints to bound models ([#300](https://github.com/hetznercloud/hcloud-python/issues/300)) ([6d46d06](https://github.com/hetznercloud/hcloud-python/commit/6d46d06c42e2e86e88b32a74d7fbd588911cc8ad)) +- **firewalls:** add `applied_to_resources` to `FirewallResource` ([#297](https://github.com/hetznercloud/hcloud-python/issues/297)) ([55d2b20](https://github.com/hetznercloud/hcloud-python/commit/55d2b2043ec1e3a040eb9e360ca0dc0c299ad60f)) ### Bug Fixes -* missing BaseDomain base class inheritance ([#303](https://github.com/hetznercloud/hcloud-python/issues/303)) ([0ee7598](https://github.com/hetznercloud/hcloud-python/commit/0ee759856cb1352f6cc538b7ef86a91cd20380f2)) - +- missing BaseDomain base class inheritance ([#303](https://github.com/hetznercloud/hcloud-python/issues/303)) ([0ee7598](https://github.com/hetznercloud/hcloud-python/commit/0ee759856cb1352f6cc538b7ef86a91cd20380f2)) ### Dependencies -* update actions/checkout action to v4 ([#295](https://github.com/hetznercloud/hcloud-python/issues/295)) ([c02b446](https://github.com/hetznercloud/hcloud-python/commit/c02b4468f0e499791bbee8fe48fe7a737985df1f)) -* update dependency sphinx to >=7.2.2,<7.3 ([#291](https://github.com/hetznercloud/hcloud-python/issues/291)) ([10234ea](https://github.com/hetznercloud/hcloud-python/commit/10234ea7bf51a427b18f2b5605d9ffa7ac5f5ee8)) -* update dependency sphinx to v7 ([#211](https://github.com/hetznercloud/hcloud-python/issues/211)) ([f635c94](https://github.com/hetznercloud/hcloud-python/commit/f635c94c23b8ae49283b9b7fcb4fe7b948b203b9)) -* update pre-commit hook asottile/pyupgrade to v3.11.0 ([#298](https://github.com/hetznercloud/hcloud-python/issues/298)) ([4bbd0cc](https://github.com/hetznercloud/hcloud-python/commit/4bbd0ccb0f606e2f90f8242951d3f4d9b86d7aea)) -* update pre-commit hook asottile/pyupgrade to v3.11.1 ([#299](https://github.com/hetznercloud/hcloud-python/issues/299)) ([2f9fcd7](https://github.com/hetznercloud/hcloud-python/commit/2f9fcd7bb80efb8da6eafab0ee70a8dda93eb6f1)) -* update pre-commit hook asottile/pyupgrade to v3.13.0 ([#301](https://github.com/hetznercloud/hcloud-python/issues/301)) ([951dbf3](https://github.com/hetznercloud/hcloud-python/commit/951dbf3e3b3816ffaeb44a583251a5a3a4b90b70)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.3 ([#294](https://github.com/hetznercloud/hcloud-python/issues/294)) ([381e336](https://github.com/hetznercloud/hcloud-python/commit/381e336ff1259fa26cb6abae3b7341cb16229a4b)) -* update pre-commit hook psf/black to v23.9.1 ([#296](https://github.com/hetznercloud/hcloud-python/issues/296)) ([4374a7b](https://github.com/hetznercloud/hcloud-python/commit/4374a7be9f244a72f1fc0c2dd76357cf63f19bfd)) - +- update actions/checkout action to v4 ([#295](https://github.com/hetznercloud/hcloud-python/issues/295)) ([c02b446](https://github.com/hetznercloud/hcloud-python/commit/c02b4468f0e499791bbee8fe48fe7a737985df1f)) +- update dependency sphinx to >=7.2.2,<7.3 ([#291](https://github.com/hetznercloud/hcloud-python/issues/291)) ([10234ea](https://github.com/hetznercloud/hcloud-python/commit/10234ea7bf51a427b18f2b5605d9ffa7ac5f5ee8)) +- update dependency sphinx to v7 ([#211](https://github.com/hetznercloud/hcloud-python/issues/211)) ([f635c94](https://github.com/hetznercloud/hcloud-python/commit/f635c94c23b8ae49283b9b7fcb4fe7b948b203b9)) +- update pre-commit hook asottile/pyupgrade to v3.11.0 ([#298](https://github.com/hetznercloud/hcloud-python/issues/298)) ([4bbd0cc](https://github.com/hetznercloud/hcloud-python/commit/4bbd0ccb0f606e2f90f8242951d3f4d9b86d7aea)) +- update pre-commit hook asottile/pyupgrade to v3.11.1 ([#299](https://github.com/hetznercloud/hcloud-python/issues/299)) ([2f9fcd7](https://github.com/hetznercloud/hcloud-python/commit/2f9fcd7bb80efb8da6eafab0ee70a8dda93eb6f1)) +- update pre-commit hook asottile/pyupgrade to v3.13.0 ([#301](https://github.com/hetznercloud/hcloud-python/issues/301)) ([951dbf3](https://github.com/hetznercloud/hcloud-python/commit/951dbf3e3b3816ffaeb44a583251a5a3a4b90b70)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.3 ([#294](https://github.com/hetznercloud/hcloud-python/issues/294)) ([381e336](https://github.com/hetznercloud/hcloud-python/commit/381e336ff1259fa26cb6abae3b7341cb16229a4b)) +- update pre-commit hook psf/black to v23.9.1 ([#296](https://github.com/hetznercloud/hcloud-python/issues/296)) ([4374a7b](https://github.com/hetznercloud/hcloud-python/commit/4374a7be9f244a72f1fc0c2dd76357cf63f19bfd)) ### Documentation -* load token from env in examples scripts ([#302](https://github.com/hetznercloud/hcloud-python/issues/302)) ([f18c9a6](https://github.com/hetznercloud/hcloud-python/commit/f18c9a60e045743b26892eeb1fe9e5737a63c11f)) +- load token from env in examples scripts ([#302](https://github.com/hetznercloud/hcloud-python/issues/302)) ([f18c9a6](https://github.com/hetznercloud/hcloud-python/commit/f18c9a60e045743b26892eeb1fe9e5737a63c11f)) ## [1.28.0](https://github.com/hetznercloud/hcloud-python/compare/v1.27.2...v1.28.0) (2023-08-17) - ### Features -* add load balancer target health status field ([#288](https://github.com/hetznercloud/hcloud-python/issues/288)) ([5780418](https://github.com/hetznercloud/hcloud-python/commit/5780418f00a42e20cccacec6e030e464105807ba)) -* implement resource actions clients ([#252](https://github.com/hetznercloud/hcloud-python/issues/252)) ([4bb9a97](https://github.com/hetznercloud/hcloud-python/commit/4bb9a9730eadea9fd0569d5d11b7585dbb5da157)) - +- add load balancer target health status field ([#288](https://github.com/hetznercloud/hcloud-python/issues/288)) ([5780418](https://github.com/hetznercloud/hcloud-python/commit/5780418f00a42e20cccacec6e030e464105807ba)) +- implement resource actions clients ([#252](https://github.com/hetznercloud/hcloud-python/issues/252)) ([4bb9a97](https://github.com/hetznercloud/hcloud-python/commit/4bb9a9730eadea9fd0569d5d11b7585dbb5da157)) ### Dependencies -* update dependency coverage to >=7.3,<7.4 ([#286](https://github.com/hetznercloud/hcloud-python/issues/286)) ([a4df4fa](https://github.com/hetznercloud/hcloud-python/commit/a4df4fa1cc7a17e1afdea1c33f4428a8a594a011)) -* update dependency mypy to >=1.5,<1.6 ([#284](https://github.com/hetznercloud/hcloud-python/issues/284)) ([9dd5c81](https://github.com/hetznercloud/hcloud-python/commit/9dd5c8110bf679c13e8e6ba08e760019b4dae706)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.2 ([#287](https://github.com/hetznercloud/hcloud-python/issues/287)) ([6bf03cb](https://github.com/hetznercloud/hcloud-python/commit/6bf03cb9ab1203f172e1634d28a99a7cb3210ad0)) - +- update dependency coverage to >=7.3,<7.4 ([#286](https://github.com/hetznercloud/hcloud-python/issues/286)) ([a4df4fa](https://github.com/hetznercloud/hcloud-python/commit/a4df4fa1cc7a17e1afdea1c33f4428a8a594a011)) +- update dependency mypy to >=1.5,<1.6 ([#284](https://github.com/hetznercloud/hcloud-python/issues/284)) ([9dd5c81](https://github.com/hetznercloud/hcloud-python/commit/9dd5c8110bf679c13e8e6ba08e760019b4dae706)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.2 ([#287](https://github.com/hetznercloud/hcloud-python/issues/287)) ([6bf03cb](https://github.com/hetznercloud/hcloud-python/commit/6bf03cb9ab1203f172e1634d28a99a7cb3210ad0)) ### Documentation -* fail on warning ([#289](https://github.com/hetznercloud/hcloud-python/issues/289)) ([e61300e](https://github.com/hetznercloud/hcloud-python/commit/e61300eda7f0ba15e0a91cce3e4b8f7542ed42c8)) +- fail on warning ([#289](https://github.com/hetznercloud/hcloud-python/issues/289)) ([e61300e](https://github.com/hetznercloud/hcloud-python/commit/e61300eda7f0ba15e0a91cce3e4b8f7542ed42c8)) ## [1.27.2](https://github.com/hetznercloud/hcloud-python/compare/v1.27.1...v1.27.2) (2023-08-09) - ### Documentation -* fix python references ([#281](https://github.com/hetznercloud/hcloud-python/issues/281)) ([0c0518e](https://github.com/hetznercloud/hcloud-python/commit/0c0518e38e8c6ebe280ee85259480fb5671c2d84)) +- fix python references ([#281](https://github.com/hetznercloud/hcloud-python/issues/281)) ([0c0518e](https://github.com/hetznercloud/hcloud-python/commit/0c0518e38e8c6ebe280ee85259480fb5671c2d84)) ## [1.27.1](https://github.com/hetznercloud/hcloud-python/compare/v1.27.0...v1.27.1) (2023-08-08) - ### Bug Fixes -* missing long_description content_type in setup.py ([#279](https://github.com/hetznercloud/hcloud-python/issues/279)) ([6d79d1d](https://github.com/hetznercloud/hcloud-python/commit/6d79d1d18d3731c3db70184c841428e9c4b2a32c)) +- missing long_description content_type in setup.py ([#279](https://github.com/hetznercloud/hcloud-python/issues/279)) ([6d79d1d](https://github.com/hetznercloud/hcloud-python/commit/6d79d1d18d3731c3db70184c841428e9c4b2a32c)) ## [1.27.0](https://github.com/hetznercloud/hcloud-python/compare/v1.26.0...v1.27.0) (2023-08-08) - ### Features -* add global request timeout option ([#271](https://github.com/hetznercloud/hcloud-python/issues/271)) ([07a663f](https://github.com/hetznercloud/hcloud-python/commit/07a663fd8628d305a7461a90a94c61a97c12421b)) -* reexport references in parent ressources modules ([#256](https://github.com/hetznercloud/hcloud-python/issues/256)) ([854c12b](https://github.com/hetznercloud/hcloud-python/commit/854c12bbde3a5f0dcc77cabe72ecab2fd72fbac0)) -* the package is now typed ([#265](https://github.com/hetznercloud/hcloud-python/issues/265)) ([da8baa5](https://github.com/hetznercloud/hcloud-python/commit/da8baa551628fb759c790871362fef1e3666c56b)) - +- add global request timeout option ([#271](https://github.com/hetznercloud/hcloud-python/issues/271)) ([07a663f](https://github.com/hetznercloud/hcloud-python/commit/07a663fd8628d305a7461a90a94c61a97c12421b)) +- reexport references in parent ressources modules ([#256](https://github.com/hetznercloud/hcloud-python/issues/256)) ([854c12b](https://github.com/hetznercloud/hcloud-python/commit/854c12bbde3a5f0dcc77cabe72ecab2fd72fbac0)) +- the package is now typed ([#265](https://github.com/hetznercloud/hcloud-python/issues/265)) ([da8baa5](https://github.com/hetznercloud/hcloud-python/commit/da8baa551628fb759c790871362fef1e3666c56b)) ### Bug Fixes -* allow omitting `datacenter` when creating a primary ip ([#171](https://github.com/hetznercloud/hcloud-python/issues/171)) ([4375dc6](https://github.com/hetznercloud/hcloud-python/commit/4375dc6ec351207380a011ec35e1397bf2bd17e9)) -* ineffective doc strings ([#266](https://github.com/hetznercloud/hcloud-python/issues/266)) ([bb34df9](https://github.com/hetznercloud/hcloud-python/commit/bb34df9390030e70f39bb82c92f4040eef18eb3b)) -* invalid attribute in placement group ([#258](https://github.com/hetznercloud/hcloud-python/issues/258)) ([23b3607](https://github.com/hetznercloud/hcloud-python/commit/23b36079d997d28d73cb9edc9a51a8c3b4481d7e)) - +- allow omitting `datacenter` when creating a primary ip ([#171](https://github.com/hetznercloud/hcloud-python/issues/171)) ([4375dc6](https://github.com/hetznercloud/hcloud-python/commit/4375dc6ec351207380a011ec35e1397bf2bd17e9)) +- ineffective doc strings ([#266](https://github.com/hetznercloud/hcloud-python/issues/266)) ([bb34df9](https://github.com/hetznercloud/hcloud-python/commit/bb34df9390030e70f39bb82c92f4040eef18eb3b)) +- invalid attribute in placement group ([#258](https://github.com/hetznercloud/hcloud-python/issues/258)) ([23b3607](https://github.com/hetznercloud/hcloud-python/commit/23b36079d997d28d73cb9edc9a51a8c3b4481d7e)) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.10.1 ([#261](https://github.com/hetznercloud/hcloud-python/issues/261)) ([efa5780](https://github.com/hetznercloud/hcloud-python/commit/efa5780d0de3080bffe43994c064a0f1bcf6da43)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.1 ([#269](https://github.com/hetznercloud/hcloud-python/issues/269)) ([2239b0b](https://github.com/hetznercloud/hcloud-python/commit/2239b0bc9beae457215c6514b0b823cc84a4a463)) -* update pre-commit hook pycqa/flake8 to v6.1.0 ([#260](https://github.com/hetznercloud/hcloud-python/issues/260)) ([fd01384](https://github.com/hetznercloud/hcloud-python/commit/fd013842f7f94e98520ed403a8cd91b68a4c4e5c)) - +- update pre-commit hook asottile/pyupgrade to v3.10.1 ([#261](https://github.com/hetznercloud/hcloud-python/issues/261)) ([efa5780](https://github.com/hetznercloud/hcloud-python/commit/efa5780d0de3080bffe43994c064a0f1bcf6da43)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.1 ([#269](https://github.com/hetznercloud/hcloud-python/issues/269)) ([2239b0b](https://github.com/hetznercloud/hcloud-python/commit/2239b0bc9beae457215c6514b0b823cc84a4a463)) +- update pre-commit hook pycqa/flake8 to v6.1.0 ([#260](https://github.com/hetznercloud/hcloud-python/issues/260)) ([fd01384](https://github.com/hetznercloud/hcloud-python/commit/fd013842f7f94e98520ed403a8cd91b68a4c4e5c)) ### Documentation -* update documentation ([#247](https://github.com/hetznercloud/hcloud-python/issues/247)) ([e63741f](https://github.com/hetznercloud/hcloud-python/commit/e63741fab50524f4e4098af5c77f806915ae93c8)) -* update hetzner logo ([#264](https://github.com/hetznercloud/hcloud-python/issues/264)) ([ee79851](https://github.com/hetznercloud/hcloud-python/commit/ee79851dbf00e50d7f6b398fd4323f3e14831831)) +- update documentation ([#247](https://github.com/hetznercloud/hcloud-python/issues/247)) ([e63741f](https://github.com/hetznercloud/hcloud-python/commit/e63741fab50524f4e4098af5c77f806915ae93c8)) +- update hetzner logo ([#264](https://github.com/hetznercloud/hcloud-python/issues/264)) ([ee79851](https://github.com/hetznercloud/hcloud-python/commit/ee79851dbf00e50d7f6b398fd4323f3e14831831)) ## [1.26.0](https://github.com/hetznercloud/hcloud-python/compare/v1.25.0...v1.26.0) (2023-07-19) - ### Features -* add __repr__ method to domains ([#246](https://github.com/hetznercloud/hcloud-python/issues/246)) ([4c22765](https://github.com/hetznercloud/hcloud-python/commit/4c227659bfb61551e44c41315b135039576960d3)) -* drop support for python 3.7 ([#242](https://github.com/hetznercloud/hcloud-python/issues/242)) ([2ce71e9](https://github.com/hetznercloud/hcloud-python/commit/2ce71e9ded5e9bb87ce96519ce59db942f4f9670)) +- add **repr** method to domains ([#246](https://github.com/hetznercloud/hcloud-python/issues/246)) ([4c22765](https://github.com/hetznercloud/hcloud-python/commit/4c227659bfb61551e44c41315b135039576960d3)) +- drop support for python 3.7 ([#242](https://github.com/hetznercloud/hcloud-python/issues/242)) ([2ce71e9](https://github.com/hetznercloud/hcloud-python/commit/2ce71e9ded5e9bb87ce96519ce59db942f4f9670)) ## [1.25.0](https://github.com/hetznercloud/hcloud-python/compare/v1.24.0...v1.25.0) (2023-07-14) - ### Features -* add details to raise exceptions ([#240](https://github.com/hetznercloud/hcloud-python/issues/240)) ([cf64e54](https://github.com/hetznercloud/hcloud-python/commit/cf64e549a2b28aea91062dea67db8733b4ecdd6f)) -* move hcloud.hcloud module to hcloud._client ([#243](https://github.com/hetznercloud/hcloud-python/issues/243)) ([413472d](https://github.com/hetznercloud/hcloud-python/commit/413472d7af1602b872a9b56324b9bffd0067eee6)) - +- add details to raise exceptions ([#240](https://github.com/hetznercloud/hcloud-python/issues/240)) ([cf64e54](https://github.com/hetznercloud/hcloud-python/commit/cf64e549a2b28aea91062dea67db8733b4ecdd6f)) +- move hcloud.hcloud module to hcloud.\_client ([#243](https://github.com/hetznercloud/hcloud-python/issues/243)) ([413472d](https://github.com/hetznercloud/hcloud-python/commit/413472d7af1602b872a9b56324b9bffd0067eee6)) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.9.0 ([#238](https://github.com/hetznercloud/hcloud-python/issues/238)) ([0053ded](https://github.com/hetznercloud/hcloud-python/commit/0053ded5a1d0c2407134706830dd8ff3d4d1e8ce)) -* update pre-commit hook pre-commit/mirrors-prettier to v3 ([#235](https://github.com/hetznercloud/hcloud-python/issues/235)) ([047d4e1](https://github.com/hetznercloud/hcloud-python/commit/047d4e173a53e91252d57d01b2e95def1c4949d9)) -* update pre-commit hook psf/black to v23.7.0 ([#239](https://github.com/hetznercloud/hcloud-python/issues/239)) ([443bf26](https://github.com/hetznercloud/hcloud-python/commit/443bf262cb524dd674d2007db8100fec94dab80d)) +- update pre-commit hook asottile/pyupgrade to v3.9.0 ([#238](https://github.com/hetznercloud/hcloud-python/issues/238)) ([0053ded](https://github.com/hetznercloud/hcloud-python/commit/0053ded5a1d0c2407134706830dd8ff3d4d1e8ce)) +- update pre-commit hook pre-commit/mirrors-prettier to v3 ([#235](https://github.com/hetznercloud/hcloud-python/issues/235)) ([047d4e1](https://github.com/hetznercloud/hcloud-python/commit/047d4e173a53e91252d57d01b2e95def1c4949d9)) +- update pre-commit hook psf/black to v23.7.0 ([#239](https://github.com/hetznercloud/hcloud-python/issues/239)) ([443bf26](https://github.com/hetznercloud/hcloud-python/commit/443bf262cb524dd674d2007db8100fec94dab80d)) ## [1.24.0](https://github.com/hetznercloud/hcloud-python/compare/v1.23.1...v1.24.0) (2023-07-03) - ### Features -* revert remove python-dateutil dependency ([#231](https://github.com/hetznercloud/hcloud-python/issues/231)) ([945bfde](https://github.com/hetznercloud/hcloud-python/commit/945bfde2ff0f64896e5c4d017e69236913e9d9dd)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) - +- revert remove python-dateutil dependency ([#231](https://github.com/hetznercloud/hcloud-python/issues/231)) ([945bfde](https://github.com/hetznercloud/hcloud-python/commit/945bfde2ff0f64896e5c4d017e69236913e9d9dd)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.8.0 ([#232](https://github.com/hetznercloud/hcloud-python/issues/232)) ([27f21bc](https://github.com/hetznercloud/hcloud-python/commit/27f21bc41e17a800a8a3bed1df7935e7fb31de42)) +- update pre-commit hook asottile/pyupgrade to v3.8.0 ([#232](https://github.com/hetznercloud/hcloud-python/issues/232)) ([27f21bc](https://github.com/hetznercloud/hcloud-python/commit/27f21bc41e17a800a8a3bed1df7935e7fb31de42)) ## [1.23.1](https://github.com/hetznercloud/hcloud-python/compare/v1.23.0...v1.23.1) (2023-06-30) - ### Bug Fixes -* handle Z timezone in ISO8601 datetime format ([#228](https://github.com/hetznercloud/hcloud-python/issues/228)) ([6a5c3f4](https://github.com/hetznercloud/hcloud-python/commit/6a5c3f42c092610c4a82cb79c0052499563549dc)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) +- handle Z timezone in ISO8601 datetime format ([#228](https://github.com/hetznercloud/hcloud-python/issues/228)) ([6a5c3f4](https://github.com/hetznercloud/hcloud-python/commit/6a5c3f42c092610c4a82cb79c0052499563549dc)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) ## [1.23.0](https://github.com/hetznercloud/hcloud-python/compare/v1.22.0...v1.23.0) (2023-06-26) - ### Features -* remove python-dateutil dependency ([#221](https://github.com/hetznercloud/hcloud-python/issues/221)) ([8ea4aa0](https://github.com/hetznercloud/hcloud-python/commit/8ea4aa0ad12e85eeb14c81dfa2195e1a6ee79a76)) - +- remove python-dateutil dependency ([#221](https://github.com/hetznercloud/hcloud-python/issues/221)) ([8ea4aa0](https://github.com/hetznercloud/hcloud-python/commit/8ea4aa0ad12e85eeb14c81dfa2195e1a6ee79a76)) ### Bug Fixes -* **isos:** invalid name for include_wildcard_architecture argument ([#222](https://github.com/hetznercloud/hcloud-python/issues/222)) ([c3dfcab](https://github.com/hetznercloud/hcloud-python/commit/c3dfcaba44d88fcf6913a6e68caee2afde06e551)) - +- **isos:** invalid name for include_wildcard_architecture argument ([#222](https://github.com/hetznercloud/hcloud-python/issues/222)) ([c3dfcab](https://github.com/hetznercloud/hcloud-python/commit/c3dfcaba44d88fcf6913a6e68caee2afde06e551)) ### Dependencies -* update dependency pytest to >=7.4,<7.5 ([#217](https://github.com/hetznercloud/hcloud-python/issues/217)) ([11e1f45](https://github.com/hetznercloud/hcloud-python/commit/11e1f455611b17a22328b3422d0b800552ea91e3)) +- update dependency pytest to >=7.4,<7.5 ([#217](https://github.com/hetznercloud/hcloud-python/issues/217)) ([11e1f45](https://github.com/hetznercloud/hcloud-python/commit/11e1f455611b17a22328b3422d0b800552ea91e3)) ## [1.22.0](https://github.com/hetznercloud/hcloud-python/compare/v1.21.0...v1.22.0) (2023-06-22) - ### Features -* adhere to PEP 517 ([#213](https://github.com/hetznercloud/hcloud-python/issues/213)) ([7a19add](https://github.com/hetznercloud/hcloud-python/commit/7a19addd8b5200f8e61360657964233e7bfae13d)) -* bump required python version to >=3.7 ([#198](https://github.com/hetznercloud/hcloud-python/issues/198)) ([62d89f9](https://github.com/hetznercloud/hcloud-python/commit/62d89f94a8a86babd8ab238443054ca4cd9411ef)) -* **network:** add field expose_routes_to_vswitch ([#208](https://github.com/hetznercloud/hcloud-python/issues/208)) ([5321182](https://github.com/hetznercloud/hcloud-python/commit/5321182d084d03484431c8ad27da12875d255768)) -* setup exception hierarchy ([#199](https://github.com/hetznercloud/hcloud-python/issues/199)) ([8466645](https://github.com/hetznercloud/hcloud-python/commit/846664576a126472289464c0345eb9108c5f46d4)) - +- adhere to PEP 517 ([#213](https://github.com/hetznercloud/hcloud-python/issues/213)) ([7a19add](https://github.com/hetznercloud/hcloud-python/commit/7a19addd8b5200f8e61360657964233e7bfae13d)) +- bump required python version to >=3.7 ([#198](https://github.com/hetznercloud/hcloud-python/issues/198)) ([62d89f9](https://github.com/hetznercloud/hcloud-python/commit/62d89f94a8a86babd8ab238443054ca4cd9411ef)) +- **network:** add field expose_routes_to_vswitch ([#208](https://github.com/hetznercloud/hcloud-python/issues/208)) ([5321182](https://github.com/hetznercloud/hcloud-python/commit/5321182d084d03484431c8ad27da12875d255768)) +- setup exception hierarchy ([#199](https://github.com/hetznercloud/hcloud-python/issues/199)) ([8466645](https://github.com/hetznercloud/hcloud-python/commit/846664576a126472289464c0345eb9108c5f46d4)) ### Dependencies -* update actions/setup-python action to v4 ([#209](https://github.com/hetznercloud/hcloud-python/issues/209)) ([aeee575](https://github.com/hetznercloud/hcloud-python/commit/aeee575a8ea7c4a1afe312a2cc2624ee564a1408)) -* update actions/stale action to v8 ([#210](https://github.com/hetznercloud/hcloud-python/issues/210)) ([cb13230](https://github.com/hetznercloud/hcloud-python/commit/cb13230e570acdbb0287c678b4cee52a0a08a170)) -* update pre-commit hook asottile/pyupgrade to v3.7.0 ([#205](https://github.com/hetznercloud/hcloud-python/issues/205)) ([c46c5a4](https://github.com/hetznercloud/hcloud-python/commit/c46c5a49fcc127a21c73e958aa074ff37a2b9664)) +- update actions/setup-python action to v4 ([#209](https://github.com/hetznercloud/hcloud-python/issues/209)) ([aeee575](https://github.com/hetznercloud/hcloud-python/commit/aeee575a8ea7c4a1afe312a2cc2624ee564a1408)) +- update actions/stale action to v8 ([#210](https://github.com/hetznercloud/hcloud-python/issues/210)) ([cb13230](https://github.com/hetznercloud/hcloud-python/commit/cb13230e570acdbb0287c678b4cee52a0a08a170)) +- update pre-commit hook asottile/pyupgrade to v3.7.0 ([#205](https://github.com/hetznercloud/hcloud-python/issues/205)) ([c46c5a4](https://github.com/hetznercloud/hcloud-python/commit/c46c5a49fcc127a21c73e958aa074ff37a2b9664)) ## [1.21.0](https://github.com/hetznercloud/hcloud-python/compare/v1.20.0...v1.21.0) (2023-06-19) - ### Features -* add deprecation field to ServerType ([#192](https://github.com/hetznercloud/hcloud-python/issues/192)) ([4a0fce7](https://github.com/hetznercloud/hcloud-python/commit/4a0fce7da6d47a7e9094c5efd1769d3d9395b540)) - +- add deprecation field to ServerType ([#192](https://github.com/hetznercloud/hcloud-python/issues/192)) ([4a0fce7](https://github.com/hetznercloud/hcloud-python/commit/4a0fce7da6d47a7e9094c5efd1769d3d9395b540)) ### Bug Fixes -* adjust label validation for max length of 63 characters ([#194](https://github.com/hetznercloud/hcloud-python/issues/194)) ([3cba96d](https://github.com/hetznercloud/hcloud-python/commit/3cba96d261499e5f812aca7936ae9ed1e75ccd52)) - +- adjust label validation for max length of 63 characters ([#194](https://github.com/hetznercloud/hcloud-python/issues/194)) ([3cba96d](https://github.com/hetznercloud/hcloud-python/commit/3cba96d261499e5f812aca7936ae9ed1e75ccd52)) ### Documentation -* improve branding, design & fix warnings ([#191](https://github.com/hetznercloud/hcloud-python/issues/191)) ([47eb9f1](https://github.com/hetznercloud/hcloud-python/commit/47eb9f1c79e05a61084f0a639f9497beb22d6910)) -* use venv for the dev setup ([#196](https://github.com/hetznercloud/hcloud-python/issues/196)) ([93f48ff](https://github.com/hetznercloud/hcloud-python/commit/93f48ff27c0561f66e5fe871e42fc2953bab0993)) +- improve branding, design & fix warnings ([#191](https://github.com/hetznercloud/hcloud-python/issues/191)) ([47eb9f1](https://github.com/hetznercloud/hcloud-python/commit/47eb9f1c79e05a61084f0a639f9497beb22d6910)) +- use venv for the dev setup ([#196](https://github.com/hetznercloud/hcloud-python/issues/196)) ([93f48ff](https://github.com/hetznercloud/hcloud-python/commit/93f48ff27c0561f66e5fe871e42fc2953bab0993)) ## [1.20.0](https://github.com/hetznercloud/hcloud-python/compare/v1.19.0...v1.20.0) (2023-05-12) - ### Features - * **server_type:** add field for included traffic ([#185](https://github.com/hetznercloud/hcloud-python/issues/185)) ([8ae0bc6](https://github.com/hetznercloud/hcloud-python/commit/8ae0bc6e032440538f3aeb2222a9bee34adab04b)) - +- **server_type:** add field for included traffic ([#185](https://github.com/hetznercloud/hcloud-python/issues/185)) ([8ae0bc6](https://github.com/hetznercloud/hcloud-python/commit/8ae0bc6e032440538f3aeb2222a9bee34adab04b)) ## v1.19.0 (2023-04-12) @@ -515,7 +456,7 @@ You can learn more about this change in [our changelog](https://docs.hetzner.clo - chore: update tests to use released python-3.11 by @apricote in [#175](https://github.com/hetznercloud/hcloud-python/issues/175) - chore: prepare release 1.18.2 by @apricote in [#174](https://github.com/hetznercloud/hcloud-python/issues/174) -##Β v1.18.1 (2022-10-25) +## v1.18.1 (2022-10-25) - Update Github Actions by @LKaemmerling in [#165](https://github.com/hetznercloud/hcloud-python/issues/165) - Add tests for Python 3.11 by @LKaemmerling in [#167](https://github.com/hetznercloud/hcloud-python/issues/167) From b95cdcc74b669c9c77258563e39332122d03d69f Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 21 Jul 2025 10:50:39 +0200 Subject: [PATCH 076/213] docs: add note about experimental features (#520) - Document how users can learn about experimental features. - Document how developers can release experimental features. --- README.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/README.md b/README.md index a280f616..066e2906 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,21 @@ for server in servers: We support python versions until [`end-of-life`](https://devguide.python.org/versions/#status-of-python-versions). +## Experimental features + +Experimental features are published as part of our regular releases (e.g. a product +public beta). During an experimental phase, breaking changes on those features may occur +within minor releases. + +While experimental features will be announced in the release notes, you can also find +whether a python class or function is experimental in its docstring: + +``` +Experimental: + $PRODUCT is experimental, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#$SLUG for more details. +``` + ## Development First, create a virtual environment and activate it: @@ -126,6 +141,21 @@ warnings.warn( ) ``` +### Releasing experimental features + +To publish experimental features as part of regular releases: + +- an announcement, including a link to a changelog entry, must be added to the release notes. +- an `Experimental` notice, including a link to a changelog entry, must be added to the python classes and functions that are experimental: + + ```py + """ + Experimental: + $PRODUCT is experimental, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#$SLUG for more details. + """ + ``` + ## License The MIT License (MIT). Please see [`License File`](https://github.com/hetznercloud/hcloud-python/blob/main/LICENSE) for more information. From af37da751ce358a621ef6b3ea983544d86c24296 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 10:30:52 +0200 Subject: [PATCH 077/213] chore(deps): update dependency coverage to >=7.10,<7.11 (#522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [coverage](https://redirect.github.com/nedbat/coveragepy) | `>=7.9,<7.10` -> `>=7.10,<7.11` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/coverage/7.10.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/coverage/7.9.2/7.10.1?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
nedbat/coveragepy (coverage) ### [`v7.10.1`](https://redirect.github.com/nedbat/coveragepy/blob/HEAD/CHANGES.rst#Version-7101--2025-07-27) [Compare Source](https://redirect.github.com/nedbat/coveragepy/compare/7.10.0...7.10.1) - Fix: the exclusion for `if TYPE_CHECKING:` was wrong: it marked the branch as partial, but it should have been a line exclusion so the entire clause would be excluded. Improves `issue 831`\_. - Fix: changed where .pth files are written for `patch = subprocess`, closing `issue 2006`\_. .. \_issue 2006:[https://github.com/nedbat/coveragepy/issues/2006](https://redirect.github.com/nedbat/coveragepy/issues/2006)6 .. \_changes\_7-10-0: ### [`v7.10.0`](https://redirect.github.com/nedbat/coveragepy/blob/HEAD/CHANGES.rst#Version-7100--2025-07-24) [Compare Source](https://redirect.github.com/nedbat/coveragepy/compare/7.9.2...7.10.0) - A new configuration option: ":ref:`config_run_patch`" specifies named patches to work around some limitations in coverage measurement. These patches are available: - `patch = _exit` lets coverage save its data even when :func:`os._exit() ` is used to abruptly end the process. This closes long-standing `issue 310`\_ as well as its duplicates: `issue 312`*, `issue 1673`*, `issue 1845`*, and `issue 1941`*. - `patch = subprocess` measures coverage in Python subprocesses created with :mod:`subprocess`, :func:`os.system`, or one of the :func:`execv ` or :func:`spawnv ` family of functions. Closes old `issue 367`\_ and duplicate `issue 378`\_. - `patch = execv` adjusts the :func:`execv ` family of functions to save coverage data before ending the current program and starting the next. Not available on Windows. Closes `issue 43`\_ after 15 years! - The HTML report now dimly colors subsequent lines in multi-line statements. They used to have no color. This gives a better indication of the amount of code missing in the report. Closes `issue 1308`\_. - Two new exclusion patterns are part of the defaults: `...` is automatically excluded as a line and `if TYPE_CHECKING:` is excluded as a branch. Closes `issue 831`\_. - A new command-line option: `--save-signal=USR1` specifies a signal that coverage.py will listen for. When the signal is sent, the coverage data will be saved. This makes it possible to save data from within long-running processes. Thanks, `Arkady Gilinsky `\_. - A new configuration option: ":ref:`config_report_partial_also`" is a list of regexes to add as pragmas for partial branches. This parallels the ":ref:`config_report_exclude_also`" setting for adding line exclusion patterns. - A few file path configuration settings didn't allow for tilde expansion: :ref:`config_json_output`, :ref:`config_lcov_output` and :ref:`config_run_debug_file`. This is now fixed. - Wheels are included for 3.14 now that 3.14 rc1 is available. - We no longer ship a PyPy-specific wheel. PyPy will install the pure-Python wheel. Closes `issue 2001`\_. - In the very unusual situation of not having a current frame, coverage no longer crashes when using the sysmon core, fixing `issue 2005`\_. .. \_issue 43:[https://github.com/nedbat/coveragepy/issues/43](https://redirect.github.com/nedbat/coveragepy/issues/43)3 .. \_issue 310[https://github.com/nedbat/coveragepy/issues/310](https://redirect.github.com/nedbat/coveragepy/issues/310)10 .. \_issue 31[https://github.com/nedbat/coveragepy/issues/312](https://redirect.github.com/nedbat/coveragepy/issues/312)312 .. \_issue 3[https://github.com/nedbat/coveragepy/issues/367](https://redirect.github.com/nedbat/coveragepy/issues/367)/367 .. \_issue [https://github.com/nedbat/coveragepy/issues/378](https://redirect.github.com/nedbat/coveragepy/issues/378)s/378 .. \_issue[https://github.com/nedbat/coveragepy/issues/831](https://redirect.github.com/nedbat/coveragepy/issues/831)es/831 .. \_issue[https://github.com/nedbat/coveragepy/issues/1308](https://redirect.github.com/nedbat/coveragepy/issues/1308)es/1308 .. \_issu[https://github.com/nedbat/coveragepy/issues/1673](https://redirect.github.com/nedbat/coveragepy/issues/1673)ues/1673 .. \_iss[https://github.com/nedbat/coveragepy/issues/1845](https://redirect.github.com/nedbat/coveragepy/issues/1845)sues/1845 .. \_is[https://github.com/nedbat/coveragepy/issues/1941](https://redirect.github.com/nedbat/coveragepy/issues/1941)ssues/1941 .. \_[https://github.com/nedbat/coveragepy/pull/1998](https://redirect.github.com/nedbat/coveragepy/pull/1998)y/pull/1998 .. \_[https://github.com/nedbat/coveragepy/issues/2001](https://redirect.github.com/nedbat/coveragepy/issues/2001)/issues/2001 .. \[https://github.com/nedbat/coveragepy/issues/2005](https://redirect.github.com/nedbat/coveragepy/issues/2005)y/issues/2005 .. \_changes\_7-9-2:
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b8c9403c..6a228229 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.9,<7.10", + "coverage>=7.10,<7.11", "pylint>=3,<3.4", "pytest>=8,<8.5", "pytest-cov>=6,<6.3", From eda666f85c0d5678113a9c3f155d98e82fb3e7cd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 7 Aug 2025 15:38:11 +0200 Subject: [PATCH 078/213] chore(deps): update actions/download-artifact action to v5 (#523) 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 | |---|---|---|---| | [actions/download-artifact](https://redirect.github.com/actions/download-artifact) | action | major | `v4` -> `v5` | --- ### Release Notes
actions/download-artifact (actions/download-artifact) ### [`v5`](https://redirect.github.com/actions/download-artifact/compare/v4...v5) [Compare Source](https://redirect.github.com/actions/download-artifact/compare/v4...v5)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 b582f606..ac2bcef1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: python-packages path: dist/ From 4ec149bdbb01dfa256a2fbad27c50f18a26736e5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 09:51:08 +0200 Subject: [PATCH 079/213] chore(deps): update pre-commit hook pre-commit/pre-commit-hooks to v6 (#528) 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 | |---|---|---|---| | [pre-commit/pre-commit-hooks](https://redirect.github.com/pre-commit/pre-commit-hooks) | repository | major | `v5.0.0` -> `v6.0.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
pre-commit/pre-commit-hooks (pre-commit/pre-commit-hooks) ### [`v6.0.0`](https://redirect.github.com/pre-commit/pre-commit-hooks/releases/tag/v6.0.0): pre-commit-hooks v6.0.0 [Compare Source](https://redirect.github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0) #### Fixes - `check-shebang-scripts-are-executable`: improve error message. - [#​1115](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/1115) PR by [@​homebysix](https://redirect.github.com/homebysix). #### Migrating - now requires python >= 3.9. - [#​1098](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/1098) PR by [@​asottile](https://redirect.github.com/asottile). - `file-contents-sorter`: disallow `--unique` and `--ignore-case` at the same time. - [#​1095](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/1095) PR by [@​nemacysts](https://redirect.github.com/nemacysts). - [#​794](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/794) issue by [@​teksturi](https://redirect.github.com/teksturi). - Removed `check-byte-order-marker` and `fix-encoding-pragma`. - `check-byte-order-marker`: migrate to `fix-byte-order-marker`. - `fix-encoding-pragma`: migrate to `pyupgrade`. - [#​1034](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/1034) PR by [@​mxr](https://redirect.github.com/mxr). - [#​1032](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/1032) issue by [@​mxr](https://redirect.github.com/mxr). - [#​522](https://redirect.github.com/pre-commit/pre-commit-hooks/issues/522) PR by [@​jgowdy](https://redirect.github.com/jgowdy).
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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 279d7ab7..d1ebf69e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict From 706b1338fb03ae5b03518599dcd5a7841af8f7cc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 16:47:53 +0200 Subject: [PATCH 080/213] chore(deps): update actions/checkout action to v5 (#531) 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 | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v4` -> `v5` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v5`](https://redirect.github.com/actions/checkout/compare/v4...v5) [Compare Source](https://redirect.github.com/actions/checkout/compare/v4...v5)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 4b09e8e7..33948432 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@v4 + - uses: actions/checkout@v5 - name: Setup python uses: actions/setup-python@v5 @@ -25,7 +25,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python uses: actions/setup-python@v5 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ac2bcef1..61051123 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@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbc96f3d..16cb6cff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Setup python uses: actions/setup-python@v5 From d38772b00cc6a7dc164cb36fd1392e20aff7db21 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 14 Aug 2025 15:45:33 +0200 Subject: [PATCH 081/213] test: use request_mock for all bound model tests (#536) Pass `request_mock` directly to the test functions to configure the mocked HTTP requests. We do not want to update/modify protected properties (the mock object) within the client class. Allows us to move the location of the client `request` function (that is mocked) in the future, without breaking the tests. A lot of the diff is from search and replaces and breaking the test arguments onto multiple lines. This is the first patch of a larger set. The next step is to use the same technique for the actual API client (not only the bound models). --- tests/unit/actions/test_client.py | 40 ++- tests/unit/certificates/test_client.py | 63 +++-- tests/unit/conftest.py | 18 +- tests/unit/firewalls/test_client.py | 79 ++++-- tests/unit/floating_ips/test_client.py | 86 ++++-- tests/unit/images/test_client.py | 64 +++-- tests/unit/isos/test_client.py | 5 +- tests/unit/load_balancers/test_client.py | 180 ++++++++----- tests/unit/networks/test_client.py | 107 +++++--- tests/unit/placement_groups/test_client.py | 26 +- tests/unit/primary_ips/test_client.py | 73 +++-- tests/unit/server_types/test_client.py | 5 +- tests/unit/servers/test_client.py | 298 ++++++++++++++------- tests/unit/ssh_keys/test_client.py | 27 +- tests/unit/volumes/test_client.py | 98 +++++-- 15 files changed, 800 insertions(+), 369 deletions(-) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index f9943087..a6a3ce25 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import ( Action, ActionFailedException, @@ -16,31 +17,38 @@ class TestBoundAction: @pytest.fixture() - def bound_running_action(self, mocked_requests): - action_client = ActionsClient(client=mocked_requests) + def bound_running_action(self, client: Client): # Speed up tests that run `wait_until_finished` - action_client._client._poll_interval_func = lambda _: 0.0 - action_client._client._poll_max_retries = 3 + client._poll_interval_func = lambda _: 0.0 + client._poll_max_retries = 3 return BoundAction( - client=action_client, + client=client.actions, data=dict(id=14, status=Action.STATUS_RUNNING), ) def test_wait_until_finished( - self, bound_running_action, mocked_requests, running_action, successfully_action + self, + request_mock: mock.MagicMock, + bound_running_action, + running_action, + successfully_action, ): - mocked_requests.request.side_effect = [running_action, successfully_action] + request_mock.side_effect = [running_action, successfully_action] bound_running_action.wait_until_finished() - mocked_requests.request.assert_called_with(url="/actions/2", method="GET") + request_mock.assert_called_with(url="/actions/2", method="GET") assert bound_running_action.status == "success" - assert mocked_requests.request.call_count == 2 + assert request_mock.call_count == 2 def test_wait_until_finished_with_error( - self, bound_running_action, mocked_requests, running_action, failed_action + self, + request_mock: mock.MagicMock, + bound_running_action, + running_action, + failed_action, ): - mocked_requests.request.side_effect = [running_action, failed_action] + request_mock.side_effect = [running_action, failed_action] with pytest.raises(ActionFailedException) as exception_info: bound_running_action.wait_until_finished() @@ -48,9 +56,13 @@ def test_wait_until_finished_with_error( assert exception_info.value.action.id == 2 def test_wait_until_finished_max_retries( - self, bound_running_action, mocked_requests, running_action, successfully_action + self, + request_mock: mock.MagicMock, + bound_running_action, + running_action, + successfully_action, ): - mocked_requests.request.side_effect = [ + request_mock.side_effect = [ running_action, running_action, successfully_action, @@ -60,7 +72,7 @@ def test_wait_until_finished_max_retries( assert bound_running_action.status == "running" assert exception_info.value.action.id == 2 - assert mocked_requests.request.call_count == 1 + assert request_mock.call_count == 1 class TestResourceActionsClient: diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index a4e5ad2e..77a0ad0b 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.certificates import ( BoundCertificate, @@ -15,16 +16,21 @@ class TestBoundCertificate: @pytest.fixture() - def bound_certificate(self, hetzner_client): - return BoundCertificate(client=hetzner_client.certificates, data=dict(id=14)) + def bound_certificate(self, client: Client): + return BoundCertificate(client.certificates, data=dict(id=14)) @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) def test_get_actions_list( - self, hetzner_client, bound_certificate, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_certificate, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions result = bound_certificate.get_actions_list(**params) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/14/actions", method="GET", params=params ) @@ -33,23 +39,29 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" - def test_get_actions(self, hetzner_client, bound_certificate, response_get_actions): - hetzner_client.request.return_value = response_get_actions + def test_get_actions( + self, + request_mock: mock.MagicMock, + client: Client, + bound_certificate, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = bound_certificate.get_actions() params = {"page": 1, "per_page": 50} - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/14/actions", method="GET", params=params ) assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -77,32 +89,41 @@ def test_bound_certificate_init(self, certificate_response): assert bound_certificate.status.error.message == "error message" def test_update( - self, hetzner_client, bound_certificate, response_update_certificate + self, + request_mock: mock.MagicMock, + bound_certificate, + response_update_certificate, ): - hetzner_client.request.return_value = response_update_certificate + request_mock.return_value = response_update_certificate certificate = bound_certificate.update(name="New name") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/14", method="PUT", json={"name": "New name"} ) assert certificate.id == 2323 assert certificate.name == "New name" - def test_delete(self, hetzner_client, bound_certificate, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_certificate, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_certificate.delete() - hetzner_client.request.assert_called_with( - url="/certificates/14", method="DELETE" - ) + request_mock.assert_called_with(url="/certificates/14", method="DELETE") assert delete_success is True def test_retry_issuance( - self, hetzner_client, bound_certificate, response_retry_issuance_action + self, + request_mock: mock.MagicMock, + bound_certificate, + response_retry_issuance_action, ): - hetzner_client.request.return_value = response_retry_issuance_action + request_mock.return_value = response_retry_issuance_action action = bound_certificate.retry_issuance() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/14/actions/retry", method="POST" ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 0dd4df46..fb620c20 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,3 +1,5 @@ +# pylint: disable=redefined-outer-name + from __future__ import annotations from unittest import mock @@ -7,12 +9,16 @@ from hcloud import Client -@pytest.fixture(autouse=True, scope="function") -def mocked_requests(): - patcher = mock.patch("hcloud._client.requests") - mocked_requests = patcher.start() - yield mocked_requests - patcher.stop() +@pytest.fixture() +def request_mock() -> mock.MagicMock: + return mock.MagicMock() + + +@pytest.fixture() +def client(request_mock) -> Client: + c = Client(token="TOKEN") + c.request = request_mock + return c @pytest.fixture() diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index 1226c61f..bb3f9402 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.firewalls import ( BoundFirewall, @@ -18,8 +19,8 @@ class TestBoundFirewall: @pytest.fixture() - def bound_firewall(self, hetzner_client): - return BoundFirewall(client=hetzner_client.firewalls, data=dict(id=1)) + def bound_firewall(self, client: Client): + return BoundFirewall(client.firewalls, data=dict(id=1)) def test_bound_firewall_init(self, firewall_response): bound_firewall = BoundFirewall( @@ -75,11 +76,16 @@ def test_bound_firewall_init(self, firewall_response): "params", [{}, {"sort": ["created"], "page": 1, "per_page": 2}] ) def test_get_actions_list( - self, hetzner_client, bound_firewall, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_firewall, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions result = bound_firewall.get_actions_list(**params) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions", method="GET", params=params ) @@ -88,35 +94,45 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" @pytest.mark.parametrize("params", [{}, {"sort": ["created"]}]) def test_get_actions( - self, hetzner_client, bound_firewall, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_firewall, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions actions = bound_firewall.get_actions(**params) params.update({"page": 1, "per_page": 50}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions", method="GET", params=params ) assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" - def test_update(self, hetzner_client, bound_firewall, response_update_firewall): - hetzner_client.request.return_value = response_update_firewall + def test_update( + self, + request_mock: mock.MagicMock, + bound_firewall, + response_update_firewall, + ): + request_mock.return_value = response_update_firewall firewall = bound_firewall.update( name="New Corporate Intranet Protection", labels={} ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1", method="PUT", json={"name": "New Corporate Intranet Protection", "labels": {}}, @@ -125,14 +141,23 @@ def test_update(self, hetzner_client, bound_firewall, response_update_firewall): assert firewall.id == 38 assert firewall.name == "New Corporate Intranet Protection" - def test_delete(self, hetzner_client, bound_firewall): + def test_delete( + self, + request_mock: mock.MagicMock, + bound_firewall, + ): delete_success = bound_firewall.delete() - hetzner_client.request.assert_called_with(url="/firewalls/1", method="DELETE") + request_mock.assert_called_with(url="/firewalls/1", method="DELETE") assert delete_success is True - def test_set_rules(self, hetzner_client, bound_firewall, response_set_rules): - hetzner_client.request.return_value = response_set_rules + def test_set_rules( + self, + request_mock: mock.MagicMock, + bound_firewall, + response_set_rules, + ): + request_mock.return_value = response_set_rules actions = bound_firewall.set_rules( [ FirewallRule( @@ -143,7 +168,7 @@ def test_set_rules(self, hetzner_client, bound_firewall, response_set_rules): ) ] ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/set_rules", method="POST", json={ @@ -162,13 +187,16 @@ def test_set_rules(self, hetzner_client, bound_firewall, response_set_rules): assert actions[0].progress == 100 def test_apply_to_resources( - self, hetzner_client, bound_firewall, response_set_rules + self, + request_mock: mock.MagicMock, + bound_firewall, + response_set_rules, ): - hetzner_client.request.return_value = response_set_rules + request_mock.return_value = response_set_rules actions = bound_firewall.apply_to_resources( [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/apply_to_resources", method="POST", json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, @@ -178,13 +206,16 @@ def test_apply_to_resources( assert actions[0].progress == 100 def test_remove_from_resources( - self, hetzner_client, bound_firewall, response_set_rules + self, + request_mock: mock.MagicMock, + bound_firewall, + response_set_rules, ): - hetzner_client.request.return_value = response_set_rules + request_mock.return_value = response_set_rules actions = bound_firewall.remove_from_resources( [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/remove_from_resources", method="POST", json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index 1f1160c6..d48c85cb 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.floating_ips import BoundFloatingIP, FloatingIP, FloatingIPsClient from hcloud.locations import BoundLocation, Location @@ -12,8 +13,8 @@ class TestBoundFloatingIP: @pytest.fixture() - def bound_floating_ip(self, hetzner_client): - return BoundFloatingIP(client=hetzner_client.floating_ips, data=dict(id=14)) + def bound_floating_ip(self, client: Client): + return BoundFloatingIP(client.floating_ips, data=dict(id=14)) def test_bound_floating_ip_init(self, floating_ip_response): bound_floating_ip = BoundFloatingIP( @@ -41,10 +42,16 @@ def test_bound_floating_ip_init(self, floating_ip_response): assert bound_floating_ip.home_location.latitude == 50.47612 assert bound_floating_ip.home_location.longitude == 12.370071 - def test_get_actions(self, hetzner_client, bound_floating_ip, response_get_actions): - hetzner_client.request.return_value = response_get_actions + def test_get_actions( + self, + request_mock: mock.MagicMock, + client: Client, + bound_floating_ip, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = bound_floating_ip.get_actions(sort="id") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14/actions", method="GET", params={"sort": "id", "page": 1, "per_page": 50}, @@ -52,18 +59,21 @@ def test_get_actions(self, hetzner_client, bound_floating_ip, response_get_actio assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" def test_update( - self, hetzner_client, bound_floating_ip, response_update_floating_ip + self, + request_mock: mock.MagicMock, + bound_floating_ip, + response_update_floating_ip, ): - hetzner_client.request.return_value = response_update_floating_ip + request_mock.return_value = response_update_floating_ip floating_ip = bound_floating_ip.update( description="New description", name="New name" ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14", method="PUT", json={"description": "New description", "name": "New name"}, @@ -73,19 +83,27 @@ def test_update( assert floating_ip.description == "New description" assert floating_ip.name == "New name" - def test_delete(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_floating_ip, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_floating_ip.delete() - hetzner_client.request.assert_called_with( - url="/floating_ips/14", method="DELETE" - ) + request_mock.assert_called_with(url="/floating_ips/14", method="DELETE") assert delete_success is True - def test_change_protection(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_floating_ip.change_protection(True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14/actions/change_protection", method="POST", json={"delete": True}, @@ -97,28 +115,44 @@ def test_change_protection(self, hetzner_client, bound_floating_ip, generic_acti @pytest.mark.parametrize( "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) ) - def test_assign(self, hetzner_client, bound_floating_ip, server, generic_action): - hetzner_client.request.return_value = generic_action + def test_assign( + self, + request_mock: mock.MagicMock, + bound_floating_ip, + server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_floating_ip.assign(server) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14/actions/assign", method="POST", json={"server": 1} ) assert action.id == 1 assert action.progress == 0 - def test_unassign(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_unassign( + self, + request_mock: mock.MagicMock, + bound_floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_floating_ip.unassign() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14/actions/unassign", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_change_dns_ptr(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + bound_floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/14/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index cf6ff2f9..62b42bcc 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -6,6 +6,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer @@ -13,8 +14,8 @@ class TestBoundImage: @pytest.fixture() - def bound_image(self, hetzner_client): - return BoundImage(client=hetzner_client.images, data=dict(id=14)) + def bound_image(self, client: Client): + return BoundImage(client.images, data=dict(id=14)) def test_bound_image_init(self, image_response): bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) @@ -50,11 +51,16 @@ def test_bound_image_init(self, image_response): "params", [{}, {"sort": ["status"], "page": 1, "per_page": 2}] ) def test_get_actions_list( - self, hetzner_client, bound_image, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_image, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions result = bound_image.get_actions_list(**params) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/images/14/actions", method="GET", params=params ) @@ -63,35 +69,45 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @pytest.mark.parametrize("params", [{}, {"sort": ["status"]}]) def test_get_actions( - self, hetzner_client, bound_image, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_image, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions actions = bound_image.get_actions(**params) params.update({"page": 1, "per_page": 50}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/images/14/actions", method="GET", params=params ) assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" - def test_update(self, hetzner_client, bound_image, response_update_image): - hetzner_client.request.return_value = response_update_image + def test_update( + self, + request_mock: mock.MagicMock, + bound_image, + response_update_image, + ): + request_mock.return_value = response_update_image image = bound_image.update( description="My new Image description", type="snapshot", labels={} ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/images/14", method="PUT", json={ @@ -104,17 +120,27 @@ def test_update(self, hetzner_client, bound_image, response_update_image): assert image.id == 4711 assert image.description == "My new Image description" - def test_delete(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_image, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_image.delete() - hetzner_client.request.assert_called_with(url="/images/14", method="DELETE") + request_mock.assert_called_with(url="/images/14", method="DELETE") assert delete_success is True - def test_change_protection(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_image, + generic_action, + ): + request_mock.return_value = generic_action action = bound_image.change_protection(True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/images/14/actions/change_protection", method="POST", json={"delete": True}, diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index 7e71f8dc..9c824377 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -6,13 +6,14 @@ import pytest +from hcloud import Client from hcloud.isos import BoundIso, IsosClient class TestBoundIso: @pytest.fixture() - def bound_iso(self, hetzner_client): - return BoundIso(client=hetzner_client.isos, data=dict(id=14)) + def bound_iso(self, client: Client): + return BoundIso(client.isos, data=dict(id=14)) def test_bound_iso_init(self, iso_response): bound_iso = BoundIso(client=mock.MagicMock(), data=iso_response["iso"]) diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 02ce1e50..9010ec7c 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.load_balancer_types import LoadBalancerType from hcloud.load_balancers import ( @@ -24,8 +25,8 @@ class TestBoundLoadBalancer: @pytest.fixture() - def bound_load_balancer(self, hetzner_client): - return BoundLoadBalancer(client=hetzner_client.load_balancers, data=dict(id=14)) + def bound_load_balancer(self, client: Client): + return BoundLoadBalancer(client.load_balancers, data=dict(id=14)) def test_bound_load_balancer_init(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( @@ -37,11 +38,16 @@ def test_bound_load_balancer_init(self, response_load_balancer): @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) def test_get_actions_list( - self, hetzner_client, bound_load_balancer, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_load_balancer, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions result = bound_load_balancer.get_actions_list(**params) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions", method="GET", params=params ) @@ -50,35 +56,43 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @pytest.mark.parametrize("params", [{}]) def test_get_actions( - self, hetzner_client, bound_load_balancer, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_load_balancer, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions actions = bound_load_balancer.get_actions(**params) params.update({"page": 1, "per_page": 50}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions", method="GET", params=params ) assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" def test_update( - self, hetzner_client, bound_load_balancer, response_update_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_update_load_balancer, ): - hetzner_client.request.return_value = response_update_load_balancer + request_mock.return_value = response_update_load_balancer load_balancer = bound_load_balancer.update(name="new-name", labels={}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14", method="PUT", json={"name": "new-name", "labels": {}}, @@ -87,28 +101,31 @@ def test_update( assert load_balancer.id == 4711 assert load_balancer.name == "new-name" - def test_delete(self, hetzner_client, generic_action, bound_load_balancer): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_load_balancer, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_load_balancer.delete() - hetzner_client.request.assert_called_with( - url="/load_balancers/14", method="DELETE" - ) + request_mock.assert_called_with(url="/load_balancers/14", method="DELETE") assert delete_success is True def test_get_metrics( self, - hetzner_client, - response_get_metrics, + request_mock: mock.MagicMock, bound_load_balancer: BoundLoadBalancer, + response_get_metrics, ): - hetzner_client.request.return_value = response_get_metrics + request_mock.return_value = response_get_metrics response = bound_load_balancer.get_metrics( type=["requests_per_second"], start="2023-12-14T16:55:32+01:00", end="2023-12-14T16:55:32+01:00", ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/metrics", method="GET", params={ @@ -121,12 +138,15 @@ def test_get_metrics( assert len(response.metrics.time_series["requests_per_second"]["values"]) == 3 def test_add_service( - self, hetzner_client, response_add_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_add_service, ): - hetzner_client.request.return_value = response_add_service + request_mock.return_value = response_add_service service = LoadBalancerService(listen_port=80, protocol="http") action = bound_load_balancer.add_service(service) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"protocol": "http", "listen_port": 80}, url="/load_balancers/14/actions/add_service", method="POST", @@ -137,12 +157,15 @@ def test_add_service( assert action.command == "add_service" def test_delete_service( - self, hetzner_client, response_delete_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_delete_service, ): - hetzner_client.request.return_value = response_delete_service + request_mock.return_value = response_delete_service service = LoadBalancerService(listen_port=12) action = bound_load_balancer.delete_service(service) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"listen_port": 12}, url="/load_balancers/14/actions/delete_service", method="POST", @@ -175,12 +198,17 @@ def test_delete_service( ], ) def test_add_target( - self, hetzner_client, response_add_target, bound_load_balancer, target, params + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_add_target, + target, + params, ): - hetzner_client.request.return_value = response_add_target + request_mock.return_value = response_add_target action = bound_load_balancer.add_target(target) params.update({"type": target.type}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions/add_target", method="POST", json=params ) @@ -212,16 +240,16 @@ def test_add_target( ) def test_remove_target( self, - hetzner_client, - response_remove_target, + request_mock: mock.MagicMock, bound_load_balancer, + response_remove_target, target, params, ): - hetzner_client.request.return_value = response_remove_target + request_mock.return_value = response_remove_target action = bound_load_balancer.remove_target(target) params.update({"type": target.type}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions/remove_target", method="POST", json=params ) @@ -230,16 +258,19 @@ def test_remove_target( assert action.command == "remove_target" def test_update_service( - self, hetzner_client, response_update_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_update_service, ): - hetzner_client.request.return_value = response_update_service + request_mock.return_value = response_update_service new_health_check = LoadBalancerHealthCheck( protocol="http", port=13, interval=1, timeout=1, retries=1 ) service = LoadBalancerService(listen_port=12, health_check=new_health_check) action = bound_load_balancer.update_service(service) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={ "listen_port": 12, "health_check": { @@ -259,12 +290,15 @@ def test_update_service( assert action.command == "update_service" def test_change_algorithm( - self, hetzner_client, response_change_algorithm, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_change_algorithm, ): - hetzner_client.request.return_value = response_change_algorithm + request_mock.return_value = response_change_algorithm algorithm = LoadBalancerAlgorithm(type="round_robin") action = bound_load_balancer.change_algorithm(algorithm) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"type": "round_robin"}, url="/load_balancers/14/actions/change_algorithm", method="POST", @@ -275,13 +309,16 @@ def test_change_algorithm( assert action.command == "change_algorithm" def test_change_dns_ptr( - self, hetzner_client, response_change_reverse_dns_entry, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_change_reverse_dns_entry, ): - hetzner_client.request.return_value = response_change_reverse_dns_entry + request_mock.return_value = response_change_reverse_dns_entry action = bound_load_balancer.change_dns_ptr( ip="1.2.3.4", dns_ptr="lb1.example.com" ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, url="/load_balancers/14/actions/change_dns_ptr", method="POST", @@ -292,11 +329,14 @@ def test_change_dns_ptr( assert action.command == "change_dns_ptr" def test_change_protection( - self, hetzner_client, response_change_protection, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_change_protection, ): - hetzner_client.request.return_value = response_change_protection + request_mock.return_value = response_change_protection action = bound_load_balancer.change_protection(delete=True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"delete": True}, url="/load_balancers/14/actions/change_protection", method="POST", @@ -307,11 +347,14 @@ def test_change_protection( assert action.command == "change_protection" def test_enable_public_interface( - self, response_enable_public_interface, hetzner_client, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_enable_public_interface, ): - hetzner_client.request.return_value = response_enable_public_interface + request_mock.return_value = response_enable_public_interface action = bound_load_balancer.enable_public_interface() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions/enable_public_interface", method="POST" ) @@ -320,11 +363,14 @@ def test_enable_public_interface( assert action.command == "enable_public_interface" def test_disable_public_interface( - self, response_disable_public_interface, hetzner_client, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_disable_public_interface, ): - hetzner_client.request.return_value = response_disable_public_interface + request_mock.return_value = response_disable_public_interface action = bound_load_balancer.disable_public_interface() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions/disable_public_interface", method="POST" ) @@ -334,13 +380,13 @@ def test_disable_public_interface( def test_attach_to_network( self, - response_attach_load_balancer_to_network, - hetzner_client, + request_mock: mock.MagicMock, bound_load_balancer, + response_attach_load_balancer_to_network, ): - hetzner_client.request.return_value = response_attach_load_balancer_to_network + request_mock.return_value = response_attach_load_balancer_to_network action = bound_load_balancer.attach_to_network(Network(id=1)) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"network": 1}, url="/load_balancers/14/actions/attach_to_network", method="POST", @@ -351,11 +397,14 @@ def test_attach_to_network( assert action.command == "attach_to_network" def test_detach_from_network( - self, response_detach_from_network, hetzner_client, bound_load_balancer + self, + request_mock: mock.MagicMock, + bound_load_balancer, + response_detach_from_network, ): - hetzner_client.request.return_value = response_detach_from_network + request_mock.return_value = response_detach_from_network action = bound_load_balancer.detach_from_network(Network(id=1)) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( json={"network": 1}, url="/load_balancers/14/actions/detach_from_network", method="POST", @@ -365,10 +414,15 @@ def test_detach_from_network( assert action.progress == 100 assert action.command == "detach_from_network" - def test_change_type(self, hetzner_client, bound_load_balancer, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_type( + self, + request_mock: mock.MagicMock, + bound_load_balancer, + generic_action, + ): + request_mock.return_value = generic_action action = bound_load_balancer.change_type(LoadBalancerType(name="lb21")) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/14/actions/change_type", method="POST", json={"load_balancer_type": "lb21"}, diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 4e986414..8e365695 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -5,6 +5,7 @@ import pytest from dateutil.parser import isoparse +from hcloud import Client from hcloud.actions import BoundAction from hcloud.networks import ( BoundNetwork, @@ -18,8 +19,8 @@ class TestBoundNetwork: @pytest.fixture() - def bound_network(self, hetzner_client): - return BoundNetwork(client=hetzner_client.networks, data=dict(id=14)) + def bound_network(self, client: Client): + return BoundNetwork(client.networks, data=dict(id=14)) def test_bound_network_init(self, network_response): bound_network = BoundNetwork( @@ -49,10 +50,16 @@ def test_bound_network_init(self, network_response): assert bound_network.routes[0].destination == "10.100.1.0/24" assert bound_network.routes[0].gateway == "10.0.1.1" - def test_get_actions(self, hetzner_client, bound_network, response_get_actions): - hetzner_client.request.return_value = response_get_actions + def test_get_actions( + self, + request_mock: mock.MagicMock, + client: Client, + bound_network, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = bound_network.get_actions(sort="id") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions", method="GET", params={"page": 1, "per_page": 50, "sort": "id"}, @@ -60,31 +67,46 @@ def test_get_actions(self, hetzner_client, bound_network, response_get_actions): assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" - def test_update(self, hetzner_client, bound_network, response_update_network): - hetzner_client.request.return_value = response_update_network + def test_update( + self, + request_mock: mock.MagicMock, + bound_network, + response_update_network, + ): + request_mock.return_value = response_update_network network = bound_network.update(name="new-name") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14", method="PUT", json={"name": "new-name"} ) assert network.id == 4711 assert network.name == "new-name" - def test_delete(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_network.delete() - hetzner_client.request.assert_called_with(url="/networks/14", method="DELETE") + request_mock.assert_called_with(url="/networks/14", method="DELETE") assert delete_success is True - def test_change_protection(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action action = bound_network.change_protection(True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/change_protection", method="POST", json={"delete": True}, @@ -93,15 +115,20 @@ def test_change_protection(self, hetzner_client, bound_network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_add_subnet(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_add_subnet( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action subnet = NetworkSubnet( type=NetworkSubnet.TYPE_CLOUD, ip_range="10.0.1.0/24", network_zone="eu-central", ) action = bound_network.add_subnet(subnet) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/add_subnet", method="POST", json={ @@ -114,11 +141,16 @@ def test_add_subnet(self, hetzner_client, bound_network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_delete_subnet(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete_subnet( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action subnet = NetworkSubnet(ip_range="10.0.1.0/24") action = bound_network.delete_subnet(subnet) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/delete_subnet", method="POST", json={"ip_range": "10.0.1.0/24"}, @@ -127,11 +159,16 @@ def test_delete_subnet(self, hetzner_client, bound_network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_add_route(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_add_route( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.add_route(route) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/add_route", method="POST", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, @@ -140,11 +177,16 @@ def test_add_route(self, hetzner_client, bound_network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_delete_route(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete_route( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.delete_route(route) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/delete_route", method="POST", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, @@ -153,10 +195,15 @@ def test_delete_route(self, hetzner_client, bound_network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_change_ip(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_ip( + self, + request_mock: mock.MagicMock, + bound_network, + generic_action, + ): + request_mock.return_value = generic_action action = bound_network.change_ip_range("10.0.0.0/12") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/14/actions/change_ip_range", method="POST", json={"ip_range": "10.0.0.0/12"}, diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 27410cf9..58abd31d 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.placement_groups import BoundPlacementGroup, PlacementGroupsClient @@ -17,10 +18,8 @@ def check_variables(placement_group, expected): class TestBoundPlacementGroup: @pytest.fixture() - def bound_placement_group(self, hetzner_client): - return BoundPlacementGroup( - client=hetzner_client.placement_groups, data=dict(id=897) - ) + def bound_placement_group(self, client: Client): + return BoundPlacementGroup(client.placement_groups, data=dict(id=897)) def test_bound_placement_group_init(self, placement_group_response): bound_placement_group = BoundPlacementGroup( @@ -32,14 +31,17 @@ def test_bound_placement_group_init(self, placement_group_response): ) def test_update( - self, hetzner_client, bound_placement_group, placement_group_response + self, + request_mock: mock.MagicMock, + bound_placement_group, + placement_group_response, ): - hetzner_client.request.return_value = placement_group_response + request_mock.return_value = placement_group_response placement_group = bound_placement_group.update( name=placement_group_response["placement_group"]["name"], labels=placement_group_response["placement_group"]["labels"], ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups/{placement_group_id}".format( placement_group_id=placement_group_response["placement_group"]["id"] ), @@ -52,11 +54,13 @@ def test_update( check_variables(placement_group, placement_group_response["placement_group"]) - def test_delete(self, hetzner_client, bound_placement_group): + def test_delete( + self, + request_mock: mock.MagicMock, + bound_placement_group, + ): delete_success = bound_placement_group.delete() - hetzner_client.request.assert_called_with( - url="/placement_groups/897", method="DELETE" - ) + request_mock.assert_called_with(url="/placement_groups/897", method="DELETE") assert delete_success is True diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 65a19be9..4e57e144 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient @@ -11,8 +12,8 @@ class TestBoundPrimaryIP: @pytest.fixture() - def bound_primary_ip(self, hetzner_client): - return BoundPrimaryIP(client=hetzner_client.primary_ips, data=dict(id=14)) + def bound_primary_ip(self, client: Client): + return BoundPrimaryIP(client.primary_ips, data=dict(id=14)) def test_bound_primary_ip_init(self, primary_ip_response): bound_primary_ip = BoundPrimaryIP( @@ -39,10 +40,15 @@ def test_bound_primary_ip_init(self, primary_ip_response): assert bound_primary_ip.datacenter.location.latitude == 50.47612 assert bound_primary_ip.datacenter.location.longitude == 12.370071 - def test_update(self, hetzner_client, bound_primary_ip, response_update_primary_ip): - hetzner_client.request.return_value = response_update_primary_ip + def test_update( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + response_update_primary_ip, + ): + request_mock.return_value = response_update_primary_ip primary_ip = bound_primary_ip.update(auto_delete=True, name="my-resource") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/14", method="PUT", json={"auto_delete": True, "name": "my-resource"}, @@ -51,19 +57,27 @@ def test_update(self, hetzner_client, bound_primary_ip, response_update_primary_ assert primary_ip.id == 42 assert primary_ip.auto_delete is True - def test_delete(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_primary_ip.delete() - hetzner_client.request.assert_called_with( - url="/primary_ips/14", method="DELETE" - ) + request_mock.assert_called_with(url="/primary_ips/14", method="DELETE") assert delete_success is True - def test_change_protection(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_primary_ip.change_protection(True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/14/actions/change_protection", method="POST", json={"delete": True}, @@ -72,10 +86,15 @@ def test_change_protection(self, hetzner_client, bound_primary_ip, generic_actio assert action.id == 1 assert action.progress == 0 - def test_assign(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_assign( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_primary_ip.assign(assignee_id=12, assignee_type="server") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/14/actions/assign", method="POST", json={"assignee_id": 12, "assignee_type": "server"}, @@ -83,19 +102,29 @@ def test_assign(self, hetzner_client, bound_primary_ip, generic_action): assert action.id == 1 assert action.progress == 0 - def test_unassign(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_unassign( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_primary_ip.unassign() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/14/actions/unassign", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_change_dns_ptr(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + bound_primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = bound_primary_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/14/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 6021a563..633f67d9 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -5,13 +5,14 @@ import pytest +from hcloud import Client from hcloud.server_types import BoundServerType, ServerTypesClient class TestBoundServerType: @pytest.fixture() - def bound_server_type(self, hetzner_client): - return BoundServerType(client=hetzner_client.server_types, data=dict(id=14)) + def bound_server_type(self, client: Client): + return BoundServerType(client.server_types, data=dict(id=14)) def test_bound_server_type_init(self, server_type_response): bound_server_type = BoundServerType( diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index cb69f4f6..214de3f0 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -4,6 +4,7 @@ import pytest +from hcloud import Client from hcloud.actions import BoundAction from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.firewalls import BoundFirewall, Firewall @@ -29,8 +30,8 @@ class TestBoundServer: @pytest.fixture() - def bound_server(self, hetzner_client): - return BoundServer(client=hetzner_client.servers, data=dict(id=14)) + def bound_server(self, client: Client): + return BoundServer(client.servers, data=dict(id=14)) def test_bound_server_init(self, response_full_server): bound_server = BoundServer( @@ -134,11 +135,16 @@ def test_bound_server_init(self, response_full_server): ], ) def test_get_actions_list( - self, hetzner_client, bound_server, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_server, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions result = bound_server.get_actions_list(**params) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions", method="GET", params=params ) @@ -147,7 +153,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "start_server" @@ -155,54 +161,69 @@ def test_get_actions_list( "params", [{"status": [Server.STATUS_RUNNING], "sort": "status"}, {}] ) def test_get_actions( - self, hetzner_client, bound_server, response_get_actions, params + self, + request_mock: mock.MagicMock, + client: Client, + bound_server, + response_get_actions, + params, ): - hetzner_client.request.return_value = response_get_actions + request_mock.return_value = response_get_actions actions = bound_server.get_actions(**params) params.update({"page": 1, "per_page": 50}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions", method="GET", params=params ) assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "start_server" - def test_update(self, hetzner_client, bound_server, response_update_server): - hetzner_client.request.return_value = response_update_server + def test_update( + self, + request_mock: mock.MagicMock, + bound_server, + response_update_server, + ): + request_mock.return_value = response_update_server server = bound_server.update(name="new-name", labels={}) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14", method="PUT", json={"name": "new-name", "labels": {}} ) assert server.id == 14 assert server.name == "new-name" - def test_delete(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.delete() - hetzner_client.request.assert_called_with(url="/servers/14", method="DELETE") + request_mock.assert_called_with(url="/servers/14", method="DELETE") assert action.id == 1 assert action.progress == 0 def test_get_metrics( self, - hetzner_client, + request_mock: mock.MagicMock, bound_server: BoundServer, response_get_metrics, ): - hetzner_client.request.return_value = response_get_metrics + request_mock.return_value = response_get_metrics response = bound_server.get_metrics( type=["cpu", "disk"], start="2023-12-14T17:40:00+01:00", end="2023-12-14T17:50:00+01:00", ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/metrics", method="GET", params={ @@ -216,50 +237,71 @@ def test_get_metrics( assert "disk.0.iops.read" in response.metrics.time_series assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 - def test_power_off(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_power_off( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.power_off() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/poweroff", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_power_on(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_power_on( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.power_on() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/poweron", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_reboot(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_reboot( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.reboot() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/reboot", method="POST" - ) + request_mock.assert_called_with(url="/servers/14/actions/reboot", method="POST") assert action.id == 1 assert action.progress == 0 - def test_reset(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_reset( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.reset() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/reset", method="POST" - ) + request_mock.assert_called_with(url="/servers/14/actions/reset", method="POST") assert action.id == 1 assert action.progress == 0 - def test_shutdown(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_shutdown( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.shutdown() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/shutdown", method="POST" ) @@ -267,11 +309,14 @@ def test_shutdown(self, hetzner_client, bound_server, generic_action): assert action.progress == 0 def test_reset_password( - self, hetzner_client, bound_server, response_server_reset_password + self, + request_mock: mock.MagicMock, + bound_server, + response_server_reset_password, ): - hetzner_client.request.return_value = response_server_reset_password + request_mock.return_value = response_server_reset_password response = bound_server.reset_password() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/reset_password", method="POST" ) @@ -279,10 +324,15 @@ def test_reset_password( assert response.action.progress == 0 assert response.root_password == "YItygq1v3GYjjMomLaKc" - def test_change_type(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_type( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/change_type", method="POST", json={"server_type": "cx11", "upgrade_disk": True}, @@ -292,11 +342,14 @@ def test_change_type(self, hetzner_client, bound_server, generic_action): assert action.progress == 0 def test_enable_rescue( - self, hetzner_client, bound_server, response_server_enable_rescue + self, + request_mock: mock.MagicMock, + bound_server, + response_server_enable_rescue, ): - hetzner_client.request.return_value = response_server_enable_rescue + request_mock.return_value = response_server_enable_rescue response = bound_server.enable_rescue(type="linux64") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/enable_rescue", method="POST", json={"type": "linux64"}, @@ -306,10 +359,15 @@ def test_enable_rescue( assert response.action.progress == 0 assert response.root_password == "YItygq1v3GYjjMomLaKc" - def test_disable_rescue(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_disable_rescue( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.disable_rescue() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/disable_rescue", method="POST" ) @@ -317,11 +375,14 @@ def test_disable_rescue(self, hetzner_client, bound_server, generic_action): assert action.progress == 0 def test_create_image( - self, hetzner_client, bound_server, response_server_create_image + self, + request_mock: mock.MagicMock, + bound_server, + response_server_create_image, ): - hetzner_client.request.return_value = response_server_create_image + request_mock.return_value = response_server_create_image response = bound_server.create_image(description="my image", type="snapshot") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/create_image", method="POST", json={"description": "my image", "type": "snapshot"}, @@ -331,13 +392,18 @@ def test_create_image( assert response.action.progress == 0 assert response.image.description == "my image" - def test_rebuild(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_rebuild( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action response = bound_server.rebuild( Image(name="ubuntu-20.04"), return_response=True, ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/rebuild", method="POST", json={"image": "ubuntu-20.04"}, @@ -347,30 +413,45 @@ def test_rebuild(self, hetzner_client, bound_server, generic_action): assert response.action.progress == 0 assert response.root_password is None or isinstance(response.root_password, str) - def test_enable_backup(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_enable_backup( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.enable_backup() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/enable_backup", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_disable_backup(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_disable_backup( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.disable_backup() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/disable_backup", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_attach_iso(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_attach_iso( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/attach_iso", method="POST", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, @@ -379,20 +460,30 @@ def test_attach_iso(self, hetzner_client, bound_server, generic_action): assert action.id == 1 assert action.progress == 0 - def test_detach_iso(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_detach_iso( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.detach_iso() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/detach_iso", method="POST" ) assert action.id == 1 assert action.progress == 0 - def test_change_dns_ptr(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.change_dns_ptr("1.2.3.4", "example.com") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, @@ -401,10 +492,15 @@ def test_change_dns_ptr(self, hetzner_client, bound_server, generic_action): assert action.id == 1 assert action.progress == 0 - def test_change_protection(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_server.change_protection(True, True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/change_protection", method="POST", json={"delete": True, "rebuild": True}, @@ -414,11 +510,14 @@ def test_change_protection(self, hetzner_client, bound_server, generic_action): assert action.progress == 0 def test_request_console( - self, hetzner_client, bound_server, response_server_request_console + self, + request_mock: mock.MagicMock, + bound_server, + response_server_request_console, ): - hetzner_client.request.return_value = response_server_request_console + request_mock.return_value = response_server_request_console response = bound_server.request_console() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/request_console", method="POST" ) @@ -434,13 +533,17 @@ def test_request_console( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_attach_to_network( - self, hetzner_client, bound_server, network, response_attach_to_network + self, + request_mock: mock.MagicMock, + bound_server, + network, + response_attach_to_network, ): - hetzner_client.request.return_value = response_attach_to_network + request_mock.return_value = response_attach_to_network action = bound_server.attach_to_network( network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] ) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/attach_to_network", method="POST", json={ @@ -458,11 +561,15 @@ def test_attach_to_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_detach_from_network( - self, hetzner_client, bound_server, network, response_detach_from_network + self, + request_mock: mock.MagicMock, + bound_server, + network, + response_detach_from_network, ): - hetzner_client.request.return_value = response_detach_from_network + request_mock.return_value = response_detach_from_network action = bound_server.detach_from_network(network) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/detach_from_network", method="POST", json={"network": 4711}, @@ -476,11 +583,15 @@ def test_detach_from_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_change_alias_ips( - self, hetzner_client, bound_server, network, response_change_alias_ips + self, + request_mock: mock.MagicMock, + bound_server, + network, + response_change_alias_ips, ): - hetzner_client.request.return_value = response_change_alias_ips + request_mock.return_value = response_change_alias_ips action = bound_server.change_alias_ips(network, ["10.0.1.2", "10.0.1.3"]) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/change_alias_ips", method="POST", json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, @@ -496,14 +607,14 @@ def test_change_alias_ips( ) def test_add_to_placement_group( self, - hetzner_client, + request_mock: mock.MagicMock, bound_server, placement_group, response_add_to_placement_group, ): - hetzner_client.request.return_value = response_add_to_placement_group + request_mock.return_value = response_add_to_placement_group action = bound_server.add_to_placement_group(placement_group) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/add_to_placement_group", method="POST", json={"placement_group": 897}, @@ -514,11 +625,14 @@ def test_add_to_placement_group( assert action.command == "add_to_placement_group" def test_remove_from_placement_group( - self, hetzner_client, bound_server, response_remove_from_placement_group + self, + request_mock: mock.MagicMock, + bound_server, + response_remove_from_placement_group, ): - hetzner_client.request.return_value = response_remove_from_placement_group + request_mock.return_value = response_remove_from_placement_group action = bound_server.remove_from_placement_group() - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/14/actions/remove_from_placement_group", method="POST" ) diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 8a64a89f..e11f5205 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -4,13 +4,14 @@ import pytest +from hcloud import Client from hcloud.ssh_keys import BoundSSHKey, SSHKey, SSHKeysClient class TestBoundSSHKey: @pytest.fixture() - def bound_ssh_key(self, hetzner_client): - return BoundSSHKey(client=hetzner_client.ssh_keys, data=dict(id=14)) + def bound_ssh_key(self, client: Client): + return BoundSSHKey(client.ssh_keys, data=dict(id=14)) def test_bound_ssh_key_init(self, ssh_key_response): bound_ssh_key = BoundSSHKey( @@ -25,20 +26,30 @@ def test_bound_ssh_key_init(self, ssh_key_response): ) assert bound_ssh_key.public_key == "ssh-rsa AAAjjk76kgf...Xt" - def test_update(self, hetzner_client, bound_ssh_key, response_update_ssh_key): - hetzner_client.request.return_value = response_update_ssh_key + def test_update( + self, + request_mock: mock.MagicMock, + bound_ssh_key, + response_update_ssh_key, + ): + request_mock.return_value = response_update_ssh_key ssh_key = bound_ssh_key.update(name="New name") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/ssh_keys/14", method="PUT", json={"name": "New name"} ) assert ssh_key.id == 2323 assert ssh_key.name == "New name" - def test_delete(self, hetzner_client, bound_ssh_key, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_ssh_key, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_ssh_key.delete() - hetzner_client.request.assert_called_with(url="/ssh_keys/14", method="DELETE") + request_mock.assert_called_with(url="/ssh_keys/14", method="DELETE") assert delete_success is True diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 1133a417..5662d23e 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -5,6 +5,7 @@ import pytest from dateutil.parser import isoparse +from hcloud import Client from hcloud.actions import BoundAction from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server @@ -13,8 +14,8 @@ class TestBoundVolume: @pytest.fixture() - def bound_volume(self, hetzner_client): - return BoundVolume(client=hetzner_client.volumes, data=dict(id=14)) + def bound_volume(self, client: Client): + return BoundVolume(client.volumes, data=dict(id=14)) def test_bound_volume_init(self, volume_response): bound_volume = BoundVolume( @@ -41,10 +42,16 @@ def test_bound_volume_init(self, volume_response): assert bound_volume.location.latitude == 50.47612 assert bound_volume.location.longitude == 12.370071 - def test_get_actions(self, hetzner_client, bound_volume, response_get_actions): - hetzner_client.request.return_value = response_get_actions + def test_get_actions( + self, + request_mock: mock.MagicMock, + client: Client, + bound_volume, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = bound_volume.get_actions(sort="id") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14/actions", method="GET", params={"page": 1, "per_page": 50, "sort": "id"}, @@ -52,31 +59,46 @@ def test_get_actions(self, hetzner_client, bound_volume, response_get_actions): assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions + assert actions[0]._client == client.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" - def test_update(self, hetzner_client, bound_volume, response_update_volume): - hetzner_client.request.return_value = response_update_volume + def test_update( + self, + request_mock: mock.MagicMock, + bound_volume, + response_update_volume, + ): + request_mock.return_value = response_update_volume volume = bound_volume.update(name="new-name") - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14", method="PUT", json={"name": "new-name"} ) assert volume.id == 4711 assert volume.name == "new-name" - def test_delete(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + bound_volume, + generic_action, + ): + request_mock.return_value = generic_action delete_success = bound_volume.delete() - hetzner_client.request.assert_called_with(url="/volumes/14", method="DELETE") + request_mock.assert_called_with(url="/volumes/14", method="DELETE") assert delete_success is True - def test_change_protection(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + bound_volume, + generic_action, + ): + request_mock.return_value = generic_action action = bound_volume.change_protection(True) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14/actions/change_protection", method="POST", json={"delete": True}, @@ -88,10 +110,16 @@ def test_change_protection(self, hetzner_client, bound_volume, generic_action): @pytest.mark.parametrize( "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) ) - def test_attach(self, hetzner_client, bound_volume, server, generic_action): - hetzner_client.request.return_value = generic_action + def test_attach( + self, + request_mock: mock.MagicMock, + bound_volume, + server, + generic_action, + ): + request_mock.return_value = generic_action action = bound_volume.attach(server) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14/actions/attach", method="POST", json={"server": 1} ) assert action.id == 1 @@ -101,11 +129,15 @@ def test_attach(self, hetzner_client, bound_volume, server, generic_action): "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) ) def test_attach_with_automount( - self, hetzner_client, bound_volume, server, generic_action + self, + request_mock: mock.MagicMock, + bound_volume, + server, + generic_action, ): - hetzner_client.request.return_value = generic_action + request_mock.return_value = generic_action action = bound_volume.attach(server, False) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14/actions/attach", method="POST", json={"server": 1, "automount": False}, @@ -113,19 +145,27 @@ def test_attach_with_automount( assert action.id == 1 assert action.progress == 0 - def test_detach(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action + def test_detach( + self, + request_mock: mock.MagicMock, + bound_volume, + generic_action, + ): + request_mock.return_value = generic_action action = bound_volume.detach() - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/detach", method="POST" - ) + request_mock.assert_called_with(url="/volumes/14/actions/detach", method="POST") assert action.id == 1 assert action.progress == 0 - def test_resize(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action + def test_resize( + self, + request_mock: mock.MagicMock, + bound_volume, + generic_action, + ): + request_mock.return_value = generic_action action = bound_volume.resize(50) - hetzner_client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/14/actions/resize", method="POST", json={"size": 50} ) assert action.id == 1 From 9096813558319cff88f07080f23c7fd2e0429308 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 14 Aug 2025 15:48:34 +0200 Subject: [PATCH 082/213] test: use request_mock for all clients tests (#537) Similar to #536 but focused on the resources clients. Pass request_mock directly to the test functions to configure the mocked HTTP requests. We do not want to update/modify protected properties (the mock object) within the client class. Allows us to move the location of the client request function (that is mocked) in the future, without breaking the tests. This also starts adding typing information to the tests, which should open the door for linting our test code as well. A lot of the diff is from search and replaces and breaking the test arguments onto multiple lines. This is the second patch of a larger set. Based on top of #536 --- tests/unit/actions/test_client.py | 86 ++-- tests/unit/certificates/test_client.py | 147 ++++-- tests/unit/conftest.py | 9 - tests/unit/datacenters/test_client.py | 61 ++- tests/unit/firewalls/test_client.py | 175 ++++--- tests/unit/floating_ips/test_client.py | 205 +++++--- tests/unit/images/test_client.py | 156 ++++-- tests/unit/isos/test_client.py | 58 ++- tests/unit/load_balancer_types/test_client.py | 57 ++- tests/unit/load_balancers/test_client.py | 124 +++-- tests/unit/locations/test_client.py | 63 ++- tests/unit/networks/test_client.py | 282 +++++++---- tests/unit/placement_groups/test_client.py | 65 ++- tests/unit/primary_ips/test_client.py | 173 +++++-- tests/unit/server_types/test_client.py | 54 +- tests/unit/servers/test_client.py | 463 ++++++++++++------ tests/unit/ssh_keys/test_client.py | 108 ++-- tests/unit/volumes/test_client.py | 223 ++++++--- 18 files changed, 1682 insertions(+), 827 deletions(-) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index a6a3ce25..ed207400 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -77,15 +77,18 @@ def test_wait_until_finished_max_retries( class TestResourceActionsClient: @pytest.fixture() - def actions_client(self): - return ResourceActionsClient(client=mock.MagicMock(), resource="/resource") + def actions_client(self, client: Client): + return ResourceActionsClient(client, resource="/resource") - def test_get_by_id(self, actions_client, generic_action): - actions_client._client.request.return_value = generic_action + def test_get_by_id( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action, + ): + request_mock.return_value = generic_action action = actions_client.get_by_id(1) - actions_client._client.request.assert_called_with( - url="/resource/actions/1", method="GET" - ) + request_mock.assert_called_with(url="/resource/actions/1", method="GET") assert action._client == actions_client._client.actions assert action.id == 1 assert action.command == "stop_server" @@ -94,10 +97,16 @@ def test_get_by_id(self, actions_client, generic_action): "params", [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], ) - def test_get_list(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list + def test_get_list( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action_list, + params, + ): + request_mock.return_value = generic_action_list result = actions_client.get_list(**params) - actions_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/resource/actions", method="GET", params=params ) @@ -118,13 +127,19 @@ def test_get_list(self, actions_client, generic_action_list, params): assert action2.command == "stop_server" @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) - def test_get_all(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list + def test_get_all( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action_list, + params, + ): + request_mock.return_value = generic_action_list actions = actions_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - actions_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/resource/actions", method="GET", params=params ) @@ -144,15 +159,18 @@ def test_get_all(self, actions_client, generic_action_list, params): class TestActionsClient: @pytest.fixture() - def actions_client(self): - return ActionsClient(client=mock.MagicMock()) + def actions_client(self, client: Client): + return ActionsClient(client) - def test_get_by_id(self, actions_client, generic_action): - actions_client._client.request.return_value = generic_action + def test_get_by_id( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action, + ): + request_mock.return_value = generic_action action = actions_client.get_by_id(1) - actions_client._client.request.assert_called_with( - url="/actions/1", method="GET" - ) + request_mock.assert_called_with(url="/actions/1", method="GET") assert action._client == actions_client._client.actions assert action.id == 1 assert action.command == "stop_server" @@ -161,13 +179,17 @@ def test_get_by_id(self, actions_client, generic_action): "params", [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], ) - def test_get_list(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list + def test_get_list( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action_list, + params, + ): + request_mock.return_value = generic_action_list with pytest.deprecated_call(): result = actions_client.get_list(**params) - actions_client._client.request.assert_called_with( - url="/actions", method="GET", params=params - ) + request_mock.assert_called_with(url="/actions", method="GET", params=params) assert result.meta is not None @@ -186,16 +208,20 @@ def test_get_list(self, actions_client, generic_action_list, params): assert action2.command == "stop_server" @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) - def test_get_all(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list + def test_get_all( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + generic_action_list, + params, + ): + request_mock.return_value = generic_action_list with pytest.deprecated_call(): actions = actions_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - actions_client._client.request.assert_called_with( - url="/actions", method="GET", params=params - ) + request_mock.assert_called_with(url="/actions", method="GET", params=params) assert len(actions) == 2 diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 77a0ad0b..d8ebaf0d 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -133,15 +133,18 @@ def test_retry_issuance( class TestCertificatesClient: @pytest.fixture() - def certificates_client(self): - return CertificatesClient(client=mock.MagicMock()) + def certificates_client(self, client: Client): + return CertificatesClient(client) - def test_get_by_id(self, certificates_client, certificate_response): - certificates_client._client.request.return_value = certificate_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate_response, + ): + request_mock.return_value = certificate_response certificate = certificates_client.get_by_id(1) - certificates_client._client.request.assert_called_with( - url="/certificates/1", method="GET" - ) + request_mock.assert_called_with(url="/certificates/1", method="GET") assert certificate._client is certificates_client assert certificate.id == 2323 assert certificate.name == "My Certificate" @@ -159,10 +162,16 @@ def test_get_by_id(self, certificates_client, certificate_response): {}, ], ) - def test_get_list(self, certificates_client, two_certificates_response, params): - certificates_client._client.request.return_value = two_certificates_response + def test_get_list( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + two_certificates_response, + params, + ): + request_mock.return_value = two_certificates_response result = certificates_client.get_list(**params) - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates", method="GET", params=params ) @@ -183,12 +192,18 @@ def test_get_list(self, certificates_client, two_certificates_response, params): @pytest.mark.parametrize( "params", [{"name": "My Certificate", "label_selector": "label1"}, {}] ) - def test_get_all(self, certificates_client, two_certificates_response, params): - certificates_client._client.request.return_value = two_certificates_response + def test_get_all( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + two_certificates_response, + params, + ): + request_mock.return_value = two_certificates_response certificates = certificates_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates", method="GET", params=params ) @@ -205,12 +220,17 @@ def test_get_all(self, certificates_client, two_certificates_response, params): assert certificates2.id == 2324 assert certificates2.name == "My website cert" - def test_get_by_name(self, certificates_client, one_certificates_response): - certificates_client._client.request.return_value = one_certificates_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + one_certificates_response, + ): + request_mock.return_value = one_certificates_response certificates = certificates_client.get_by_name("My Certificate") params = {"name": "My Certificate"} - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates", method="GET", params=params ) @@ -218,14 +238,19 @@ def test_get_by_name(self, certificates_client, one_certificates_response): assert certificates.id == 2323 assert certificates.name == "My Certificate" - def test_create(self, certificates_client, certificate_response): - certificates_client._client.request.return_value = certificate_response + def test_create( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate_response, + ): + request_mock.return_value = certificate_response certificate = certificates_client.create( name="My Certificate", certificate="-----BEGIN CERTIFICATE-----\n...", private_key="-----BEGIN PRIVATE KEY-----\n...", ) - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates", method="POST", json={ @@ -240,15 +265,16 @@ def test_create(self, certificates_client, certificate_response): assert certificate.name == "My Certificate" def test_create_managed( - self, certificates_client, create_managed_certificate_response + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + create_managed_certificate_response, ): - certificates_client._client.request.return_value = ( - create_managed_certificate_response - ) + request_mock.return_value = create_managed_certificate_response create_managed_certificate_rsp = certificates_client.create_managed( name="My Certificate", domain_names=["example.com", "*.example.org"] ) - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates", method="POST", json={ @@ -268,11 +294,15 @@ def test_create_managed( [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) def test_update( - self, certificates_client, certificate, response_update_certificate + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_update_certificate, ): - certificates_client._client.request.return_value = response_update_certificate + request_mock.return_value = response_update_certificate certificate = certificates_client.update(certificate, name="New name") - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/1", method="PUT", json={"name": "New name"} ) @@ -283,12 +313,16 @@ def test_update( "certificate", [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) - def test_delete(self, certificates_client, certificate, generic_action): - certificates_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + generic_action, + ): + request_mock.return_value = generic_action delete_success = certificates_client.delete(certificate) - certificates_client._client.request.assert_called_with( - url="/certificates/1", method="DELETE" - ) + request_mock.assert_called_with(url="/certificates/1", method="DELETE") assert delete_success is True @@ -297,39 +331,47 @@ def test_delete(self, certificates_client, certificate, generic_action): [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) def test_retry_issuance( - self, certificates_client, certificate, response_retry_issuance_action + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_retry_issuance_action, ): - certificates_client._client.request.return_value = ( - response_retry_issuance_action - ) + request_mock.return_value = response_retry_issuance_action action = certificates_client.retry_issuance(certificate) - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/1/actions/retry", method="POST" ) assert action.id == 14 assert action.command == "issue_certificate" - def test_actions_get_by_id(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = certificates_client.actions.get_by_id(13) - certificates_client._client.request.assert_called_with( - url="/certificates/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/certificates/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == certificates_client._client.actions assert action.id == 13 assert action.command == "change_protection" - def test_actions_get_list(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = certificates_client.actions.get_list() - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/actions", method="GET", params={}, @@ -344,11 +386,16 @@ def test_actions_get_list(self, certificates_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "change_protection" - def test_actions_get_all(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = certificates_client.actions.get_all() - certificates_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/certificates/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index fb620c20..d5e760e9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -35,12 +35,3 @@ def generic_action(): "error": {"code": "action_failed", "message": "Action failed"}, } } - - -@pytest.fixture() -def hetzner_client(): - client = Client(token="token") - patcher = mock.patch.object(client, "request") - patcher.start() - yield client - patcher.stop() diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 3da55e37..c51331bd 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -4,6 +4,7 @@ import pytest # noqa: F401 +from hcloud import Client from hcloud.datacenters import BoundDatacenter, DatacentersClient, DatacenterServerTypes from hcloud.locations import BoundLocation @@ -58,15 +59,18 @@ def test_bound_datacenter_init(self, datacenter_response): class TestDatacentersClient: @pytest.fixture() - def datacenters_client(self): - return DatacentersClient(client=mock.MagicMock()) - - def test_get_by_id(self, datacenters_client, datacenter_response): - datacenters_client._client.request.return_value = datacenter_response + def datacenters_client(self, client: Client): + return DatacentersClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + datacenter_response, + ): + request_mock.return_value = datacenter_response datacenter = datacenters_client.get_by_id(1) - datacenters_client._client.request.assert_called_with( - url="/datacenters/1", method="GET" - ) + request_mock.assert_called_with(url="/datacenters/1", method="GET") assert datacenter._client is datacenters_client assert datacenter.id == 1 assert datacenter.name == "fsn1-dc8" @@ -74,12 +78,16 @@ def test_get_by_id(self, datacenters_client, datacenter_response): @pytest.mark.parametrize( "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, datacenters_client, two_datacenters_response, params): - datacenters_client._client.request.return_value = two_datacenters_response + def test_get_list( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + two_datacenters_response, + params, + ): + request_mock.return_value = two_datacenters_response result = datacenters_client.get_list(**params) - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params - ) + request_mock.assert_called_with(url="/datacenters", method="GET", params=params) datacenters = result.datacenters assert result.meta is not None @@ -100,14 +108,18 @@ def test_get_list(self, datacenters_client, two_datacenters_response, params): assert isinstance(datacenter2.location, BoundLocation) @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) - def test_get_all(self, datacenters_client, two_datacenters_response, params): - datacenters_client._client.request.return_value = two_datacenters_response + def test_get_all( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + two_datacenters_response, + params, + ): + request_mock.return_value = two_datacenters_response datacenters = datacenters_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params - ) + request_mock.assert_called_with(url="/datacenters", method="GET", params=params) assert len(datacenters) == 2 @@ -124,14 +136,17 @@ def test_get_all(self, datacenters_client, two_datacenters_response, params): assert datacenter2.name == "nbg1-dc3" assert isinstance(datacenter2.location, BoundLocation) - def test_get_by_name(self, datacenters_client, one_datacenters_response): - datacenters_client._client.request.return_value = one_datacenters_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + one_datacenters_response, + ): + request_mock.return_value = one_datacenters_response datacenter = datacenters_client.get_by_name("fsn1-dc8") params = {"name": "fsn1-dc8"} - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params - ) + request_mock.assert_called_with(url="/datacenters", method="GET", params=params) assert datacenter._client is datacenters_client assert datacenter.id == 1 diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index bb3f9402..e3bdf5e4 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -227,15 +227,18 @@ def test_remove_from_resources( class TestFirewallsClient: @pytest.fixture() - def firewalls_client(self): - return FirewallsClient(client=mock.MagicMock()) + def firewalls_client(self, client: Client): + return FirewallsClient(client) - def test_get_by_id(self, firewalls_client, firewall_response): - firewalls_client._client.request.return_value = firewall_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall_response, + ): + request_mock.return_value = firewall_response firewall = firewalls_client.get_by_id(1) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1", method="GET" - ) + request_mock.assert_called_with(url="/firewalls/1", method="GET") assert firewall._client is firewalls_client assert firewall.id == 38 assert firewall.name == "Corporate Intranet Protection" @@ -254,12 +257,16 @@ def test_get_by_id(self, firewalls_client, firewall_response): {}, ], ) - def test_get_list(self, firewalls_client, two_firewalls_response, params): - firewalls_client._client.request.return_value = two_firewalls_response + def test_get_list( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + two_firewalls_response, + params, + ): + request_mock.return_value = two_firewalls_response result = firewalls_client.get_list(**params) - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params - ) + request_mock.assert_called_with(url="/firewalls", method="GET", params=params) firewalls = result.firewalls assert result.meta is not None @@ -288,15 +295,19 @@ def test_get_list(self, firewalls_client, two_firewalls_response, params): {}, ], ) - def test_get_all(self, firewalls_client, two_firewalls_response, params): - firewalls_client._client.request.return_value = two_firewalls_response + def test_get_all( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + two_firewalls_response, + params, + ): + request_mock.return_value = two_firewalls_response firewalls = firewalls_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params - ) + request_mock.assert_called_with(url="/firewalls", method="GET", params=params) assert len(firewalls) == 2 @@ -311,15 +322,18 @@ def test_get_all(self, firewalls_client, two_firewalls_response, params): assert firewalls2.id == 39 assert firewalls2.name == "Corporate Extranet Protection" - def test_get_by_name(self, firewalls_client, one_firewalls_response): - firewalls_client._client.request.return_value = one_firewalls_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + one_firewalls_response, + ): + request_mock.return_value = one_firewalls_response firewall = firewalls_client.get_by_name("Corporate Intranet Protection") params = {"name": "Corporate Intranet Protection"} - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params - ) + request_mock.assert_called_with(url="/firewalls", method="GET", params=params) assert firewall._client is firewalls_client assert firewall.id == 38 @@ -328,10 +342,16 @@ def test_get_by_name(self, firewalls_client, one_firewalls_response): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, firewalls_client, firewall, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = firewalls_client.get_actions_list(firewall) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions", method="GET", params={} ) @@ -345,8 +365,13 @@ def test_get_actions_list(self, firewalls_client, firewall, response_get_actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" - def test_create(self, firewalls_client, response_create_firewall): - firewalls_client._client.request.return_value = response_create_firewall + def test_create( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_create_firewall, + ): + request_mock.return_value = response_create_firewall response = firewalls_client.create( "Corporate Intranet Protection", rules=[ @@ -366,7 +391,7 @@ def test_create(self, firewalls_client, response_create_firewall): ), ], ) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls", method="POST", json={ @@ -397,12 +422,18 @@ def test_create(self, firewalls_client, response_create_firewall): @pytest.mark.parametrize( "firewall", [Firewall(id=38), BoundFirewall(mock.MagicMock(), dict(id=38))] ) - def test_update(self, firewalls_client, firewall, response_update_firewall): - firewalls_client._client.request.return_value = response_update_firewall + def test_update( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_update_firewall, + ): + request_mock.return_value = response_update_firewall firewall = firewalls_client.update( firewall, name="New Corporate Intranet Protection", labels={} ) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/38", method="PUT", json={"name": "New Corporate Intranet Protection", "labels": {}}, @@ -414,8 +445,14 @@ def test_update(self, firewalls_client, firewall, response_update_firewall): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_set_rules(self, firewalls_client, firewall, response_set_rules): - firewalls_client._client.request.return_value = response_set_rules + def test_set_rules( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, + ): + request_mock.return_value = response_set_rules actions = firewalls_client.set_rules( firewall, [ @@ -426,7 +463,7 @@ def test_set_rules(self, firewalls_client, firewall, response_set_rules): ) ], ) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/set_rules", method="POST", json={ @@ -446,25 +483,34 @@ def test_set_rules(self, firewalls_client, firewall, response_set_rules): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, firewalls_client, firewall): + def test_delete( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + ): delete_success = firewalls_client.delete(firewall) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1", method="DELETE" - ) + request_mock.assert_called_with(url="/firewalls/1", method="DELETE") assert delete_success is True @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_apply_to_resources(self, firewalls_client, firewall, response_set_rules): - firewalls_client._client.request.return_value = response_set_rules + def test_apply_to_resources( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, + ): + request_mock.return_value = response_set_rules actions = firewalls_client.apply_to_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/apply_to_resources", method="POST", json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, @@ -477,15 +523,19 @@ def test_apply_to_resources(self, firewalls_client, firewall, response_set_rules "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) def test_remove_from_resources( - self, firewalls_client, firewall, response_set_rules + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, ): - firewalls_client._client.request.return_value = response_set_rules + request_mock.return_value = response_set_rules actions = firewalls_client.remove_from_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/1/actions/remove_from_resources", method="POST", json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, @@ -494,26 +544,32 @@ def test_remove_from_resources( assert actions[0].id == 13 assert actions[0].progress == 100 - def test_actions_get_by_id(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = firewalls_client.actions.get_by_id(13) - firewalls_client._client.request.assert_called_with( - url="/firewalls/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/firewalls/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == firewalls_client._client.actions assert action.id == 13 assert action.command == "set_firewall_rules" - def test_actions_get_list(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = firewalls_client.actions.get_list() - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/actions", method="GET", params={}, @@ -528,11 +584,16 @@ def test_actions_get_list(self, firewalls_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" - def test_actions_get_all(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = firewalls_client.actions.get_all() - firewalls_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/firewalls/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index d48c85cb..e26da382 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -163,23 +163,31 @@ def test_change_dns_ptr( class TestFloatingIPsClient: @pytest.fixture() - def floating_ips_client(self): - return FloatingIPsClient(client=mock.MagicMock()) + def floating_ips_client(self, client: Client): + return FloatingIPsClient(client) - def test_get_by_id(self, floating_ips_client, floating_ip_response): - floating_ips_client._client.request.return_value = floating_ip_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_response, + ): + request_mock.return_value = floating_ip_response bound_floating_ip = floating_ips_client.get_by_id(1) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1", method="GET" - ) + request_mock.assert_called_with(url="/floating_ips/1", method="GET") assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 assert bound_floating_ip.description == "Web Frontend" - def test_get_by_name(self, floating_ips_client, one_floating_ips_response): - floating_ips_client._client.request.return_value = one_floating_ips_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + one_floating_ips_response, + ): + request_mock.return_value = one_floating_ips_response bound_floating_ip = floating_ips_client.get_by_name("Web Frontend") - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="GET", params={"name": "Web Frontend"} ) assert bound_floating_ip._client is floating_ips_client @@ -191,10 +199,16 @@ def test_get_by_name(self, floating_ips_client, one_floating_ips_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, floating_ips_client, two_floating_ips_response, params): - floating_ips_client._client.request.return_value = two_floating_ips_response + def test_get_list( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + two_floating_ips_response, + params, + ): + request_mock.return_value = two_floating_ips_response result = floating_ips_client.get_list(**params) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="GET", params=params ) @@ -215,13 +229,19 @@ def test_get_list(self, floating_ips_client, two_floating_ips_response, params): assert bound_floating_ip2.description == "Web Backend" @pytest.mark.parametrize("params", [{"label_selector": "label1"}, {}]) - def test_get_all(self, floating_ips_client, two_floating_ips_response, params): - floating_ips_client._client.request.return_value = two_floating_ips_response + def test_get_all( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + two_floating_ips_response, + params, + ): + request_mock.return_value = two_floating_ips_response bound_floating_ips = floating_ips_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="GET", params=params ) @@ -238,12 +258,17 @@ def test_get_all(self, floating_ips_client, two_floating_ips_response, params): assert bound_floating_ip2.id == 4712 assert bound_floating_ip2.description == "Web Backend" - def test_create_with_location(self, floating_ips_client, floating_ip_response): - floating_ips_client._client.request.return_value = floating_ip_response + def test_create_with_location( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_response, + ): + request_mock.return_value = floating_ip_response response = floating_ips_client.create( "ipv6", "Web Frontend", home_location=Location(name="location") ) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="POST", json={ @@ -265,13 +290,17 @@ def test_create_with_location(self, floating_ips_client, floating_ip_response): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_create_with_server( - self, floating_ips_client, server, floating_ip_create_response + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip_create_response, ): - floating_ips_client._client.request.return_value = floating_ip_create_response + request_mock.return_value = floating_ip_create_response response = floating_ips_client.create( type="ipv6", description="Web Frontend", server=server ) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="POST", json={"description": "Web Frontend", "type": "ipv6", "server": 1}, @@ -288,13 +317,17 @@ def test_create_with_server( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_create_with_name( - self, floating_ips_client, server, floating_ip_create_response + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip_create_response, ): - floating_ips_client._client.request.return_value = floating_ip_create_response + request_mock.return_value = floating_ip_create_response response = floating_ips_client.create( type="ipv6", description="Web Frontend", name="Web Frontend" ) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips", method="POST", json={ @@ -315,10 +348,16 @@ def test_create_with_name( @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) - def test_get_actions(self, floating_ips_client, floating_ip, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions + def test_get_actions( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = floating_ips_client.get_actions(floating_ip) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/1/actions", method="GET", params={"page": 1, "per_page": 50}, @@ -335,13 +374,17 @@ def test_get_actions(self, floating_ips_client, floating_ip, response_get_action "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) def test_update( - self, floating_ips_client, floating_ip, response_update_floating_ip + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + response_update_floating_ip, ): - floating_ips_client._client.request.return_value = response_update_floating_ip + request_mock.return_value = response_update_floating_ip floating_ip = floating_ips_client.update( floating_ip, description="New description", name="New name" ) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/1", method="PUT", json={"description": "New description", "name": "New name"}, @@ -354,10 +397,16 @@ def test_update( @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = floating_ips_client.change_protection(floating_ip, True) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/1/actions/change_protection", method="POST", json={"delete": True}, @@ -369,12 +418,16 @@ def test_change_protection(self, floating_ips_client, floating_ip, generic_actio @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + generic_action, + ): + request_mock.return_value = generic_action delete_success = floating_ips_client.delete(floating_ip) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1", method="DELETE" - ) + request_mock.assert_called_with(url="/floating_ips/1", method="DELETE") assert delete_success is True @@ -388,10 +441,17 @@ def test_delete(self, floating_ips_client, floating_ip, generic_action): ), ], ) - def test_assign(self, floating_ips_client, server, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_assign( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = floating_ips_client.assign(floating_ip, server) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/12/actions/assign", method="POST", json={"server": 1} ) assert action.id == 1 @@ -401,10 +461,16 @@ def test_assign(self, floating_ips_client, server, floating_ip, generic_action): "floating_ip", [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], ) - def test_unassign(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_unassign( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = floating_ips_client.unassign(floating_ip) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/12/actions/unassign", method="POST" ) assert action.id == 1 @@ -414,12 +480,18 @@ def test_unassign(self, floating_ips_client, floating_ip, generic_action): "floating_ip", [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], ) - def test_change_dns_ptr(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + generic_action, + ): + request_mock.return_value = generic_action action = floating_ips_client.change_dns_ptr( floating_ip, "1.2.3.4", "server02.example.com" ) - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/12/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, @@ -427,26 +499,32 @@ def test_change_dns_ptr(self, floating_ips_client, floating_ip, generic_action): assert action.id == 1 assert action.progress == 0 - def test_actions_get_by_id(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = floating_ips_client.actions.get_by_id(13) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/floating_ips/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == floating_ips_client._client.actions assert action.id == 13 assert action.command == "assign_floating_ip" - def test_actions_get_list(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = floating_ips_client.actions.get_list() - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/actions", method="GET", params={}, @@ -461,11 +539,16 @@ def test_actions_get_list(self, floating_ips_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" - def test_actions_get_all(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = floating_ips_client.actions.get_all() - floating_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/floating_ips/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 62b42bcc..8e6add3a 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -152,13 +152,18 @@ def test_change_protection( class TestImagesClient: @pytest.fixture() - def images_client(self): - return ImagesClient(client=mock.MagicMock()) + def images_client(self, client: Client): + return ImagesClient(client) - def test_get_by_id(self, images_client, image_response): - images_client._client.request.return_value = image_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image_response, + ): + request_mock.return_value = image_response image = images_client.get_by_id(1) - images_client._client.request.assert_called_with(url="/images/1", method="GET") + request_mock.assert_called_with(url="/images/1", method="GET") assert image._client is images_client assert image.id == 4711 assert image.name == "ubuntu-20.04" @@ -180,12 +185,16 @@ def test_get_by_id(self, images_client, image_response): {}, ], ) - def test_get_list(self, images_client, two_images_response, params): - images_client._client.request.return_value = two_images_response + def test_get_list( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + two_images_response, + params, + ): + request_mock.return_value = two_images_response result = images_client.get_list(**params) - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params - ) + request_mock.assert_called_with(url="/images", method="GET", params=params) images = result.images assert result.meta is not None @@ -217,15 +226,19 @@ def test_get_list(self, images_client, two_images_response, params): {}, ], ) - def test_get_all(self, images_client, two_images_response, params): - images_client._client.request.return_value = two_images_response + def test_get_all( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + two_images_response, + params, + ): + request_mock.return_value = two_images_response images = images_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params - ) + request_mock.assert_called_with(url="/images", method="GET", params=params) assert len(images) == 2 @@ -240,30 +253,36 @@ def test_get_all(self, images_client, two_images_response, params): assert images2.id == 4712 assert images2.name == "ubuntu-18.10" - def test_get_by_name(self, images_client, one_images_response): - images_client._client.request.return_value = one_images_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + one_images_response, + ): + request_mock.return_value = one_images_response with pytest.deprecated_call(): image = images_client.get_by_name("ubuntu-20.04") params = {"name": "ubuntu-20.04"} - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params - ) + request_mock.assert_called_with(url="/images", method="GET", params=params) assert image._client is images_client assert image.id == 4711 assert image.name == "ubuntu-20.04" - def test_get_by_name_and_architecture(self, images_client, one_images_response): - images_client._client.request.return_value = one_images_response + def test_get_by_name_and_architecture( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + one_images_response, + ): + request_mock.return_value = one_images_response image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") params = {"name": "ubuntu-20.04", "architecture": ["x86"]} - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params - ) + request_mock.assert_called_with(url="/images", method="GET", params=params) assert image._client is images_client assert image.id == 4711 @@ -273,10 +292,16 @@ def test_get_by_name_and_architecture(self, images_client, one_images_response): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, images_client, image, response_get_actions): - images_client._client.request.return_value = response_get_actions + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = images_client.get_actions_list(image) - images_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/images/1/actions", method="GET", params={} ) @@ -293,12 +318,18 @@ def test_get_actions_list(self, images_client, image, response_get_actions): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_update(self, images_client, image, response_update_image): - images_client._client.request.return_value = response_update_image + def test_update( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + response_update_image, + ): + request_mock.return_value = response_update_image image = images_client.update( image, description="My new Image description", type="snapshot", labels={} ) - images_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/images/1", method="PUT", json={ @@ -314,10 +345,16 @@ def test_update(self, images_client, image, response_update_image): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, images_client, image, generic_action): - images_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + generic_action, + ): + request_mock.return_value = generic_action action = images_client.change_protection(image, True) - images_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/images/1/actions/change_protection", method="POST", json={"delete": True}, @@ -329,35 +366,45 @@ def test_change_protection(self, images_client, image, generic_action): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, images_client, image, generic_action): - images_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + generic_action, + ): + request_mock.return_value = generic_action delete_success = images_client.delete(image) - images_client._client.request.assert_called_with( - url="/images/1", method="DELETE" - ) + request_mock.assert_called_with(url="/images/1", method="DELETE") assert delete_success is True - def test_actions_get_by_id(self, images_client, response_get_actions): - images_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = images_client.actions.get_by_id(13) - images_client._client.request.assert_called_with( - url="/images/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/images/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == images_client._client.actions assert action.id == 13 assert action.command == "change_protection" - def test_actions_get_list(self, images_client, response_get_actions): - images_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = images_client.actions.get_list() - images_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/images/actions", method="GET", params={}, @@ -372,11 +419,16 @@ def test_actions_get_list(self, images_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "change_protection" - def test_actions_get_all(self, images_client, response_get_actions): - images_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = images_client.actions.get_all() - images_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/images/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index 9c824377..2f57f170 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -37,13 +37,18 @@ def test_bound_iso_init(self, iso_response): class TestIsosClient: @pytest.fixture() - def isos_client(self): - return IsosClient(client=mock.MagicMock()) - - def test_get_by_id(self, isos_client, iso_response): - isos_client._client.request.return_value = iso_response + def isos_client(self, client: Client): + return IsosClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + iso_response, + ): + request_mock.return_value = iso_response iso = isos_client.get_by_id(1) - isos_client._client.request.assert_called_with(url="/isos/1", method="GET") + request_mock.assert_called_with(url="/isos/1", method="GET") assert iso._client is isos_client assert iso.id == 4711 assert iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" @@ -56,12 +61,16 @@ def test_get_by_id(self, isos_client, iso_response): {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "page": 1, "per_page": 2}, ], ) - def test_get_list(self, isos_client, two_isos_response, params): - isos_client._client.request.return_value = two_isos_response + def test_get_list( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + two_isos_response, + params, + ): + request_mock.return_value = two_isos_response result = isos_client.get_list(**params) - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params - ) + request_mock.assert_called_with(url="/isos", method="GET", params=params) isos = result.isos assert result.meta is not None @@ -82,15 +91,19 @@ def test_get_list(self, isos_client, two_isos_response, params): @pytest.mark.parametrize( "params", [{}, {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1"}] ) - def test_get_all(self, isos_client, two_isos_response, params): - isos_client._client.request.return_value = two_isos_response + def test_get_all( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + two_isos_response, + params, + ): + request_mock.return_value = two_isos_response isos = isos_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params - ) + request_mock.assert_called_with(url="/isos", method="GET", params=params) assert len(isos) == 2 @@ -105,15 +118,18 @@ def test_get_all(self, isos_client, two_isos_response, params): assert isos2.id == 4712 assert isos2.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - def test_get_by_name(self, isos_client, one_isos_response): - isos_client._client.request.return_value = one_isos_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + one_isos_response, + ): + request_mock.return_value = one_isos_response iso = isos_client.get_by_name("FreeBSD-11.0-RELEASE-amd64-dvd1") params = {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1"} - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params - ) + request_mock.assert_called_with(url="/isos", method="GET", params=params) assert iso._client is isos_client assert iso.id == 4711 diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index f0a4c12e..96e65e9a 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -4,22 +4,24 @@ import pytest +from hcloud import Client from hcloud.load_balancer_types import LoadBalancerTypesClient class TestLoadBalancerTypesClient: @pytest.fixture() - def load_balancer_types_client(self): - return LoadBalancerTypesClient(client=mock.MagicMock()) - - def test_get_by_id(self, load_balancer_types_client, load_balancer_type_response): - load_balancer_types_client._client.request.return_value = ( - load_balancer_type_response - ) + def load_balancer_types_client(self, client: Client): + return LoadBalancerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type_response, + ): + request_mock.return_value = load_balancer_type_response load_balancer_type = load_balancer_types_client.get_by_id(1) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types/1", method="GET" - ) + request_mock.assert_called_with(url="/load_balancer_types/1", method="GET") assert load_balancer_type._client is load_balancer_types_client assert load_balancer_type.id == 1 assert load_balancer_type.name == "LB11" @@ -28,13 +30,15 @@ def test_get_by_id(self, load_balancer_types_client, load_balancer_type_response "params", [{"name": "LB11", "page": 1, "per_page": 10}, {"name": ""}, {}] ) def test_get_list( - self, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + two_load_balancer_types_response, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) + request_mock.return_value = two_load_balancer_types_response result = load_balancer_types_client.get_list(**params) - load_balancer_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancer_types", method="GET", params=params ) @@ -56,16 +60,18 @@ def test_get_list( @pytest.mark.parametrize("params", [{"name": "LB21"}]) def test_get_all( - self, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + two_load_balancer_types_response, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) + request_mock.return_value = two_load_balancer_types_response load_balancer_types = load_balancer_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - load_balancer_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancer_types", method="GET", params=params ) @@ -83,16 +89,17 @@ def test_get_all( assert load_balancer_types2.name == "LB21" def test_get_by_name( - self, load_balancer_types_client, one_load_balancer_types_response + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + one_load_balancer_types_response, ): - load_balancer_types_client._client.request.return_value = ( - one_load_balancer_types_response - ) + request_mock.return_value = one_load_balancer_types_response load_balancer_type = load_balancer_types_client.get_by_name("LB21") params = {"name": "LB21"} - load_balancer_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancer_types", method="GET", params=params ) diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 9010ec7c..158493c2 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -434,15 +434,18 @@ def test_change_type( class TestLoadBalancerslient: @pytest.fixture() - def load_balancers_client(self): - return LoadBalancersClient(client=mock.MagicMock()) + def load_balancers_client(self, client: Client): + return LoadBalancersClient(client) - def test_get_by_id(self, load_balancers_client, response_load_balancer): - load_balancers_client._client.request.return_value = response_load_balancer + def test_get_by_id( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_load_balancer, + ): + request_mock.return_value = response_load_balancer bound_load_balancer = load_balancers_client.get_by_id(1) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/1", method="GET" - ) + request_mock.assert_called_with(url="/load_balancers/1", method="GET") assert bound_load_balancer._client is load_balancers_client assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" @@ -464,13 +467,15 @@ def test_get_by_id(self, load_balancers_client, response_load_balancer): ], ) def test_get_list( - self, load_balancers_client, response_simple_load_balancers, params + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_simple_load_balancers, + params, ): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) + request_mock.return_value = response_simple_load_balancers result = load_balancers_client.get_list(**params) - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers", method="GET", params=params ) @@ -494,16 +499,18 @@ def test_get_list( "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] ) def test_get_all( - self, load_balancers_client, response_simple_load_balancers, params + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_simple_load_balancers, + params, ): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) + request_mock.return_value = response_simple_load_balancers bound_load_balancers = load_balancers_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers", method="GET", params=params ) @@ -520,15 +527,18 @@ def test_get_all( assert bound_load_balancer2.id == 4712 assert bound_load_balancer2.name == "Web Frontend2" - def test_get_by_name(self, load_balancers_client, response_simple_load_balancers): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) + def test_get_by_name( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_simple_load_balancers, + ): + request_mock.return_value = response_simple_load_balancers bound_load_balancer = load_balancers_client.get_by_name("Web Frontend") params = {"name": "Web Frontend"} - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers", method="GET", params=params ) @@ -536,16 +546,19 @@ def test_get_by_name(self, load_balancers_client, response_simple_load_balancers assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" - def test_create(self, load_balancers_client, response_create_load_balancer): - load_balancers_client._client.request.return_value = ( - response_create_load_balancer - ) + def test_create( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_create_load_balancer, + ): + request_mock.return_value = response_create_load_balancer response = load_balancers_client.create( "my-balancer", load_balancer_type=LoadBalancerType(name="lb11"), location=Location(id=1), ) - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers", method="POST", json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, @@ -562,13 +575,17 @@ def test_create(self, load_balancers_client, response_create_load_balancer): [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], ) def test_change_type_with_load_balancer_type_name( - self, load_balancers_client, load_balancer, generic_action + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + load_balancer, + generic_action, ): - load_balancers_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = load_balancers_client.change_type( load_balancer, LoadBalancerType(name="lb11") ) - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/1/actions/change_type", method="POST", json={"load_balancer_type": "lb11"}, @@ -582,13 +599,17 @@ def test_change_type_with_load_balancer_type_name( [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], ) def test_change_type_with_load_balancer_type_id( - self, load_balancers_client, load_balancer, generic_action + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + load_balancer, + generic_action, ): - load_balancers_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = load_balancers_client.change_type( load_balancer, LoadBalancerType(id=1) ) - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/1/actions/change_type", method="POST", json={"load_balancer_type": 1}, @@ -597,26 +618,32 @@ def test_change_type_with_load_balancer_type_id( assert action.id == 1 assert action.progress == 0 - def test_actions_get_by_id(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = load_balancers_client.actions.get_by_id(13) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/load_balancers/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == load_balancers_client._client.actions assert action.id == 13 assert action.command == "change_protection" - def test_actions_get_list(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = load_balancers_client.actions.get_list() - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/actions", method="GET", params={}, @@ -631,11 +658,16 @@ def test_actions_get_list(self, load_balancers_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "change_protection" - def test_actions_get_all(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + load_balancers_client: LoadBalancersClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = load_balancers_client.actions.get_all() - load_balancers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/load_balancers/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index c288230a..9b1b643b 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -1,23 +1,27 @@ from __future__ import annotations -from unittest import mock # noqa: F401 +from unittest import mock import pytest # noqa: F401 +from hcloud import Client from hcloud.locations import LocationsClient class TestLocationsClient: @pytest.fixture() - def locations_client(self): - return LocationsClient(client=mock.MagicMock()) - - def test_get_by_id(self, locations_client, location_response): - locations_client._client.request.return_value = location_response + def locations_client(self, client: Client): + return LocationsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + location_response, + ): + request_mock.return_value = location_response location = locations_client.get_by_id(1) - locations_client._client.request.assert_called_with( - url="/locations/1", method="GET" - ) + request_mock.assert_called_with(url="/locations/1", method="GET") assert location._client is locations_client assert location.id == 1 assert location.name == "fsn1" @@ -26,12 +30,16 @@ def test_get_by_id(self, locations_client, location_response): @pytest.mark.parametrize( "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, locations_client, two_locations_response, params): - locations_client._client.request.return_value = two_locations_response + def test_get_list( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response result = locations_client.get_list(**params) - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params - ) + request_mock.assert_called_with(url="/locations", method="GET", params=params) locations = result.locations assert result.meta is not None @@ -52,15 +60,19 @@ def test_get_list(self, locations_client, two_locations_response, params): assert location2.network_zone == "eu-central" @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) - def test_get_all(self, locations_client, two_locations_response, params): - locations_client._client.request.return_value = two_locations_response + def test_get_all( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response locations = locations_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params - ) + request_mock.assert_called_with(url="/locations", method="GET", params=params) assert len(locations) == 2 @@ -77,15 +89,18 @@ def test_get_all(self, locations_client, two_locations_response, params): assert location2.name == "nbg1" assert location2.network_zone == "eu-central" - def test_get_by_name(self, locations_client, one_locations_response): - locations_client._client.request.return_value = one_locations_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + one_locations_response, + ): + request_mock.return_value = one_locations_response location = locations_client.get_by_name("fsn1") params = {"name": "fsn1"} - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params - ) + request_mock.assert_called_with(url="/locations", method="GET", params=params) assert location._client is locations_client assert location.id == 1 diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 8e365695..a80f87b1 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -215,8 +215,8 @@ def test_change_ip( class TestNetworksClient: @pytest.fixture() - def networks_client(self): - return NetworksClient(client=mock.MagicMock()) + def networks_client(self, client: Client): + return NetworksClient(client) @pytest.fixture() def network_subnet(self): @@ -239,12 +239,15 @@ def network_vswitch_subnet(self): def network_route(self): return NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - def test_get_by_id(self, networks_client, network_response): - networks_client._client.request.return_value = network_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_response, + ): + request_mock.return_value = network_response bound_network = networks_client.get_by_id(1) - networks_client._client.request.assert_called_with( - url="/networks/1", method="GET" - ) + request_mock.assert_called_with(url="/networks/1", method="GET") assert bound_network._client is networks_client assert bound_network.id == 1 assert bound_network.name == "mynet" @@ -253,12 +256,16 @@ def test_get_by_id(self, networks_client, network_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, networks_client, two_networks_response, params): - networks_client._client.request.return_value = two_networks_response + def test_get_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + two_networks_response, + params, + ): + request_mock.return_value = two_networks_response result = networks_client.get_list(**params) - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params - ) + request_mock.assert_called_with(url="/networks", method="GET", params=params) bound_networks = result.networks assert result.meta is not None @@ -277,15 +284,19 @@ def test_get_list(self, networks_client, two_networks_response, params): assert bound_network2.name == "myanothernet" @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) - def test_get_all(self, networks_client, two_networks_response, params): - networks_client._client.request.return_value = two_networks_response + def test_get_all( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + two_networks_response, + params, + ): + request_mock.return_value = two_networks_response bound_networks = networks_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params - ) + request_mock.assert_called_with(url="/networks", method="GET", params=params) assert len(bound_networks) == 2 @@ -300,39 +311,50 @@ def test_get_all(self, networks_client, two_networks_response, params): assert bound_network2.id == 2 assert bound_network2.name == "myanothernet" - def test_get_by_name(self, networks_client, one_network_response): - networks_client._client.request.return_value = one_network_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + one_network_response, + ): + request_mock.return_value = one_network_response bound_network = networks_client.get_by_name("mynet") params = {"name": "mynet"} - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params - ) + request_mock.assert_called_with(url="/networks", method="GET", params=params) assert bound_network._client is networks_client assert bound_network.id == 1 assert bound_network.name == "mynet" - def test_create(self, networks_client, network_create_response): - networks_client._client.request.return_value = network_create_response + def test_create( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_create_response, + ): + request_mock.return_value = network_create_response networks_client.create(name="mynet", ip_range="10.0.0.0/8") - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={"name": "mynet", "ip_range": "10.0.0.0/8"}, ) def test_create_with_expose_routes_to_vswitch( - self, networks_client, network_create_response_with_expose_routes_to_vswitch + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_create_response_with_expose_routes_to_vswitch, ): - networks_client._client.request.return_value = ( + request_mock.return_value = ( network_create_response_with_expose_routes_to_vswitch ) networks_client.create( name="mynet", ip_range="10.0.0.0/8", expose_routes_to_vswitch=True ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -343,13 +365,17 @@ def test_create_with_expose_routes_to_vswitch( ) def test_create_with_subnet( - self, networks_client, network_subnet, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -366,15 +392,19 @@ def test_create_with_subnet( ) def test_create_with_subnet_vswitch( - self, networks_client, network_subnet, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response network_subnet.type = NetworkSubnet.TYPE_VSWITCH network_subnet.vswitch_id = 1000 networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -392,13 +422,17 @@ def test_create_with_subnet_vswitch( ) def test_create_with_route( - self, networks_client, network_route, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_route, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response networks_client.create( name="mynet", ip_range="10.0.0.0/8", routes=[network_route] ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -410,11 +444,12 @@ def test_create_with_route( def test_create_with_route_and_expose_routes_to_vswitch( self, + request_mock: mock.MagicMock, networks_client, network_route, network_create_response_with_expose_routes_to_vswitch, ): - networks_client._client.request.return_value = ( + request_mock.return_value = ( network_create_response_with_expose_routes_to_vswitch ) networks_client.create( @@ -423,7 +458,7 @@ def test_create_with_route_and_expose_routes_to_vswitch( routes=[network_route], expose_routes_to_vswitch=True, ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -435,16 +470,21 @@ def test_create_with_route_and_expose_routes_to_vswitch( ) def test_create_with_route_and_subnet( - self, networks_client, network_subnet, network_route, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_route, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet], routes=[network_route], ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks", method="POST", json={ @@ -464,10 +504,16 @@ def test_create_with_route_and_subnet( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, networks_client, network, response_get_actions): - networks_client._client.request.return_value = response_get_actions + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = networks_client.get_actions_list(network, sort="id") - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions", method="GET", params={"sort": "id"} ) @@ -482,12 +528,18 @@ def test_get_actions_list(self, networks_client, network, response_get_actions): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_update(self, networks_client, network, response_update_network): - networks_client._client.request.return_value = response_update_network + def test_update( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + response_update_network, + ): + request_mock.return_value = response_update_network network = networks_client.update( network, name="new-name", expose_routes_to_vswitch=True ) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1", method="PUT", json={"name": "new-name", "expose_routes_to_vswitch": True}, @@ -500,10 +552,16 @@ def test_update(self, networks_client, network, response_update_network): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + ): + request_mock.return_value = generic_action action = networks_client.change_protection(network, True) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/change_protection", method="POST", json={"delete": True}, @@ -515,23 +573,34 @@ def test_change_protection(self, networks_client, network, generic_action): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + ): + request_mock.return_value = generic_action delete_success = networks_client.delete(network) - networks_client._client.request.assert_called_with( - url="/networks/1", method="DELETE" - ) + request_mock.assert_called_with(url="/networks/1", method="DELETE") assert delete_success is True @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_add_subnet(self, networks_client, network, generic_action, network_subnet): - networks_client._client.request.return_value = generic_action + def test_add_subnet( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + network_subnet, + ): + request_mock.return_value = generic_action action = networks_client.add_subnet(network, network_subnet) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/add_subnet", method="POST", json={ @@ -548,12 +617,17 @@ def test_add_subnet(self, networks_client, network, generic_action, network_subn "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_add_subnet_vswitch( - self, networks_client, network, generic_action, network_vswitch_subnet + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + network_vswitch_subnet, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = networks_client.add_subnet(network, network_vswitch_subnet) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/add_subnet", method="POST", json={ @@ -571,12 +645,17 @@ def test_add_subnet_vswitch( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_delete_subnet( - self, networks_client, network, generic_action, network_subnet + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + network_subnet, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = networks_client.delete_subnet(network, network_subnet) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/delete_subnet", method="POST", json={"ip_range": "10.0.1.0/24"}, @@ -588,11 +667,18 @@ def test_delete_subnet( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_add_route(self, networks_client, network, generic_action, network_route): - networks_client._client.request.return_value = generic_action + def test_add_route( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + network_route, + ): + request_mock.return_value = generic_action action = networks_client.add_route(network, network_route) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/add_route", method="POST", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, @@ -605,12 +691,17 @@ def test_add_route(self, networks_client, network, generic_action, network_route "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_delete_route( - self, networks_client, network, generic_action, network_route + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + network_route, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = networks_client.delete_route(network, network_route) - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/delete_route", method="POST", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, @@ -622,10 +713,16 @@ def test_delete_route( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_change_ip_range(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_change_ip_range( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + generic_action, + ): + request_mock.return_value = generic_action action = networks_client.change_ip_range(network, "10.0.0.0/12") - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/1/actions/change_ip_range", method="POST", json={"ip_range": "10.0.0.0/12"}, @@ -634,26 +731,32 @@ def test_change_ip_range(self, networks_client, network, generic_action): assert action.id == 1 assert action.progress == 0 - def test_actions_get_by_id(self, networks_client, response_get_actions): - networks_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = networks_client.actions.get_by_id(13) - networks_client._client.request.assert_called_with( - url="/networks/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/networks/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == networks_client._client.actions assert action.id == 13 assert action.command == "add_subnet" - def test_actions_get_list(self, networks_client, response_get_actions): - networks_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = networks_client.actions.get_list() - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/actions", method="GET", params={}, @@ -668,11 +771,16 @@ def test_actions_get_list(self, networks_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "add_subnet" - def test_actions_get_all(self, networks_client, response_get_actions): - networks_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = networks_client.actions.get_all() - networks_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/networks/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 58abd31d..824c50bd 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -67,15 +67,20 @@ def test_delete( class TestPlacementGroupsClient: @pytest.fixture() - def placement_groups_client(self): - return PlacementGroupsClient(client=mock.MagicMock()) + def placement_groups_client(self, client: Client): + return PlacementGroupsClient(client) - def test_get_by_id(self, placement_groups_client, placement_group_response): - placement_groups_client._client.request.return_value = placement_group_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + placement_groups_client: PlacementGroupsClient, + placement_group_response, + ): + request_mock.return_value = placement_group_response placement_group = placement_groups_client.get_by_id( placement_group_response["placement_group"]["id"] ) - placement_groups_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups/{placement_group_id}".format( placement_group_id=placement_group_response["placement_group"]["id"] ), @@ -101,13 +106,15 @@ def test_get_by_id(self, placement_groups_client, placement_group_response): ], ) def test_get_list( - self, placement_groups_client, two_placement_groups_response, params + self, + request_mock: mock.MagicMock, + placement_groups_client: PlacementGroupsClient, + two_placement_groups_response, + params, ): - placement_groups_client._client.request.return_value = ( - two_placement_groups_response - ) + request_mock.return_value = two_placement_groups_response result = placement_groups_client.get_list(**params) - placement_groups_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups", method="GET", params=params ) @@ -137,15 +144,17 @@ def test_get_list( ], ) def test_get_all( - self, placement_groups_client, two_placement_groups_response, params + self, + request_mock: mock.MagicMock, + placement_groups_client: PlacementGroupsClient, + two_placement_groups_response, + params, ): - placement_groups_client._client.request.return_value = ( - two_placement_groups_response - ) + request_mock.return_value = two_placement_groups_response placement_groups = placement_groups_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - placement_groups_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups", method="GET", params=params ) @@ -160,16 +169,19 @@ def test_get_all( check_variables(placement_group, expected) - def test_get_by_name(self, placement_groups_client, one_placement_group_response): - placement_groups_client._client.request.return_value = ( - one_placement_group_response - ) + def test_get_by_name( + self, + request_mock: mock.MagicMock, + placement_groups_client: PlacementGroupsClient, + one_placement_group_response, + ): + request_mock.return_value = one_placement_group_response placement_group = placement_groups_client.get_by_name( one_placement_group_response["placement_groups"][0]["name"] ) params = {"name": one_placement_group_response["placement_groups"][0]["name"]} - placement_groups_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups", method="GET", params=params ) @@ -177,10 +189,13 @@ def test_get_by_name(self, placement_groups_client, one_placement_group_response placement_group, one_placement_group_response["placement_groups"][0] ) - def test_create(self, placement_groups_client, response_create_placement_group): - placement_groups_client._client.request.return_value = ( - response_create_placement_group - ) + def test_create( + self, + request_mock: mock.MagicMock, + placement_groups_client: PlacementGroupsClient, + response_create_placement_group, + ): + request_mock.return_value = response_create_placement_group response = placement_groups_client.create( name=response_create_placement_group["placement_group"]["name"], type=response_create_placement_group["placement_group"]["type"], @@ -192,7 +207,7 @@ def test_create(self, placement_groups_client, response_create_placement_group): "labels": response_create_placement_group["placement_group"]["labels"], "type": response_create_placement_group["placement_group"]["type"], } - placement_groups_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/placement_groups", method="POST", json=json ) diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 4e57e144..d3809e48 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -135,22 +135,30 @@ def test_change_dns_ptr( class TestPrimaryIPsClient: @pytest.fixture() - def primary_ips_client(self): - return PrimaryIPsClient(client=mock.MagicMock()) + def primary_ips_client(self, client: Client): + return PrimaryIPsClient(client) - def test_get_by_id(self, primary_ips_client, primary_ip_response): - primary_ips_client._client.request.return_value = primary_ip_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_response, + ): + request_mock.return_value = primary_ip_response bound_primary_ip = primary_ips_client.get_by_id(1) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1", method="GET" - ) + request_mock.assert_called_with(url="/primary_ips/1", method="GET") assert bound_primary_ip._client is primary_ips_client assert bound_primary_ip.id == 42 - def test_get_by_name(self, primary_ips_client, one_primary_ips_response): - primary_ips_client._client.request.return_value = one_primary_ips_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + one_primary_ips_response, + ): + request_mock.return_value = one_primary_ips_response bound_primary_ip = primary_ips_client.get_by_name("my-resource") - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips", method="GET", params={"name": "my-resource"} ) assert bound_primary_ip._client is primary_ips_client @@ -158,15 +166,19 @@ def test_get_by_name(self, primary_ips_client, one_primary_ips_response): assert bound_primary_ip.name == "my-resource" @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) - def test_get_all(self, primary_ips_client, all_primary_ips_response, params): - primary_ips_client._client.request.return_value = all_primary_ips_response + def test_get_all( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + all_primary_ips_response, + params, + ): + request_mock.return_value = all_primary_ips_response bound_primary_ips = primary_ips_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips", method="GET", params=params - ) + request_mock.assert_called_with(url="/primary_ips", method="GET", params=params) assert len(bound_primary_ips) == 1 @@ -176,12 +188,17 @@ def test_get_all(self, primary_ips_client, all_primary_ips_response, params): assert bound_primary_ip1.id == 42 assert bound_primary_ip1.name == "my-resource" - def test_create_with_datacenter(self, primary_ips_client, primary_ip_response): - primary_ips_client._client.request.return_value = primary_ip_response + def test_create_with_datacenter( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_response, + ): + request_mock.return_value = primary_ip_response response = primary_ips_client.create( type="ipv6", name="my-resource", datacenter=Datacenter(name="datacenter") ) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips", method="POST", json={ @@ -202,16 +219,19 @@ def test_create_with_datacenter(self, primary_ips_client, primary_ip_response): assert action is None def test_create_with_assignee_id( - self, primary_ips_client, primary_ip_create_response + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_create_response, ): - primary_ips_client._client.request.return_value = primary_ip_create_response + request_mock.return_value = primary_ip_create_response response = primary_ips_client.create( type="ipv6", name="my-ip", assignee_id=17, assignee_type="server", ) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips", method="POST", json={ @@ -234,12 +254,18 @@ def test_create_with_assignee_id( @pytest.mark.parametrize( "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] ) - def test_update(self, primary_ips_client, primary_ip, response_update_primary_ip): - primary_ips_client._client.request.return_value = response_update_primary_ip + def test_update( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + response_update_primary_ip, + ): + request_mock.return_value = response_update_primary_ip primary_ip = primary_ips_client.update( primary_ip, auto_delete=True, name="my-resource" ) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/1", method="PUT", json={"auto_delete": True, "name": "my-resource"}, @@ -252,10 +278,16 @@ def test_update(self, primary_ips_client, primary_ip, response_update_primary_ip @pytest.mark.parametrize( "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = primary_ips_client.change_protection(primary_ip, True) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/1/actions/change_protection", method="POST", json={"delete": True}, @@ -267,12 +299,16 @@ def test_change_protection(self, primary_ips_client, primary_ip, generic_action) @pytest.mark.parametrize( "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + generic_action, + ): + request_mock.return_value = generic_action delete_success = primary_ips_client.delete(primary_ip) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1", method="DELETE" - ) + request_mock.assert_called_with(url="/primary_ips/1", method="DELETE") assert delete_success is True @@ -284,11 +320,17 @@ def test_delete(self, primary_ips_client, primary_ip, generic_action): ], ) def test_assign( - self, primary_ips_client, assignee_id, assignee_type, primary_ip, generic_action + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + assignee_id, + assignee_type, + primary_ip, + generic_action, ): - primary_ips_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/12/actions/assign", method="POST", json={"assignee_id": 1, "assignee_type": "server"}, @@ -299,10 +341,16 @@ def test_assign( @pytest.mark.parametrize( "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] ) - def test_unassign(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action + def test_unassign( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = primary_ips_client.unassign(primary_ip) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/12/actions/unassign", method="POST" ) assert action.id == 1 @@ -311,12 +359,18 @@ def test_unassign(self, primary_ips_client, primary_ip, generic_action): @pytest.mark.parametrize( "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] ) - def test_change_dns_ptr(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + generic_action, + ): + request_mock.return_value = generic_action action = primary_ips_client.change_dns_ptr( primary_ip, "1.2.3.4", "server02.example.com" ) - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/12/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, @@ -324,26 +378,32 @@ def test_change_dns_ptr(self, primary_ips_client, primary_ip, generic_action): assert action.id == 1 assert action.progress == 0 - def test_actions_get_by_id(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = primary_ips_client.actions.get_by_id(13) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/primary_ips/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == primary_ips_client._client.actions assert action.id == 13 assert action.command == "assign_primary_ip" - def test_actions_get_list(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = primary_ips_client.actions.get_list() - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/actions", method="GET", params={}, @@ -358,11 +418,16 @@ def test_actions_get_list(self, primary_ips_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "assign_primary_ip" - def test_actions_get_all(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = primary_ips_client.actions.get_all() - primary_ips_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/primary_ips/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 633f67d9..92c9bef7 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -42,15 +42,18 @@ def test_bound_server_type_init(self, server_type_response): class TestServerTypesClient: @pytest.fixture() - def server_types_client(self): - return ServerTypesClient(client=mock.MagicMock()) - - def test_get_by_id(self, server_types_client, server_type_response): - server_types_client._client.request.return_value = server_type_response + def server_types_client(self, client: Client): + return ServerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + server_type_response, + ): + request_mock.return_value = server_type_response server_type = server_types_client.get_by_id(1) - server_types_client._client.request.assert_called_with( - url="/server_types/1", method="GET" - ) + request_mock.assert_called_with(url="/server_types/1", method="GET") assert server_type._client is server_types_client assert server_type.id == 1 assert server_type.name == "cx11" @@ -58,10 +61,16 @@ def test_get_by_id(self, server_types_client, server_type_response): @pytest.mark.parametrize( "params", [{"name": "cx11", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, server_types_client, two_server_types_response, params): - server_types_client._client.request.return_value = two_server_types_response + def test_get_list( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + two_server_types_response, + params, + ): + request_mock.return_value = two_server_types_response result = server_types_client.get_list(**params) - server_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/server_types", method="GET", params=params ) @@ -82,13 +91,19 @@ def test_get_list(self, server_types_client, two_server_types_response, params): assert server_types2.name == "cx21" @pytest.mark.parametrize("params", [{"name": "cx11"}]) - def test_get_all(self, server_types_client, two_server_types_response, params): - server_types_client._client.request.return_value = two_server_types_response + def test_get_all( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + two_server_types_response, + params, + ): + request_mock.return_value = two_server_types_response server_types = server_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - server_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/server_types", method="GET", params=params ) @@ -105,13 +120,18 @@ def test_get_all(self, server_types_client, two_server_types_response, params): assert server_types2.id == 2 assert server_types2.name == "cx21" - def test_get_by_name(self, server_types_client, one_server_types_response): - server_types_client._client.request.return_value = one_server_types_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + one_server_types_response, + ): + request_mock.return_value = one_server_types_response server_type = server_types_client.get_by_name("cx11") params = {"name": "cx11"} - server_types_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/server_types", method="GET", params=params ) diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 214de3f0..7f2cd2c1 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -643,15 +643,18 @@ def test_remove_from_placement_group( class TestServersClient: @pytest.fixture() - def servers_client(self): - return ServersClient(client=mock.MagicMock()) + def servers_client(self, client: Client): + return ServersClient(client) - def test_get_by_id(self, servers_client, response_simple_server): - servers_client._client.request.return_value = response_simple_server + def test_get_by_id( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_server, + ): + request_mock.return_value = response_simple_server bound_server = servers_client.get_by_id(1) - servers_client._client.request.assert_called_with( - url="/servers/1", method="GET" - ) + request_mock.assert_called_with(url="/servers/1", method="GET") assert bound_server._client is servers_client assert bound_server.id == 1 assert bound_server.name == "my-server" @@ -664,12 +667,16 @@ def test_get_by_id(self, servers_client, response_simple_server): {}, ], ) - def test_get_list(self, servers_client, response_simple_servers, params): - servers_client._client.request.return_value = response_simple_servers + def test_get_list( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + params, + ): + request_mock.return_value = response_simple_servers result = servers_client.get_list(**params) - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params - ) + request_mock.assert_called_with(url="/servers", method="GET", params=params) bound_servers = result.servers assert result.meta is not None @@ -690,15 +697,19 @@ def test_get_list(self, servers_client, response_simple_servers, params): @pytest.mark.parametrize( "params", [{"name": "server1", "label_selector": "label1"}, {}] ) - def test_get_all(self, servers_client, response_simple_servers, params): - servers_client._client.request.return_value = response_simple_servers + def test_get_all( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + params, + ): + request_mock.return_value = response_simple_servers bound_servers = servers_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params - ) + request_mock.assert_called_with(url="/servers", method="GET", params=params) assert len(bound_servers) == 2 @@ -713,31 +724,37 @@ def test_get_all(self, servers_client, response_simple_servers, params): assert bound_server2.id == 2 assert bound_server2.name == "my-server2" - def test_get_by_name(self, servers_client, response_simple_servers): - servers_client._client.request.return_value = response_simple_servers + def test_get_by_name( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + ): + request_mock.return_value = response_simple_servers bound_server = servers_client.get_by_name("my-server") params = {"name": "my-server"} - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params - ) + request_mock.assert_called_with(url="/servers", method="GET", params=params) assert bound_server._client is servers_client assert bound_server.id == 1 assert bound_server.name == "my-server" def test_create_with_datacenter( - self, servers_client, response_create_simple_server + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, ): - servers_client._client.request.return_value = response_create_simple_server + request_mock.return_value = response_create_simple_server response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(id=4711), datacenter=Datacenter(id=1), ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -761,15 +778,20 @@ def test_create_with_datacenter( assert bound_action.id == 1 assert bound_action.command == "create_server" - def test_create_with_location(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_location( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(name="ubuntu-20.04"), location=Location(name="fsn1"), ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -793,8 +815,13 @@ def test_create_with_location(self, servers_client, response_create_simple_serve assert bound_action.id == 1 assert bound_action.command == "create_server" - def test_create_with_volumes(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_volumes( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server volumes = [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -803,7 +830,7 @@ def test_create_with_volumes(self, servers_client, response_create_simple_server volumes=volumes, start_after_create=False, ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -833,8 +860,13 @@ def test_create_with_volumes(self, servers_client, response_create_simple_server assert next_actions[0].id == 13 - def test_create_with_networks(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_networks( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server networks = [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -843,7 +875,7 @@ def test_create_with_networks(self, servers_client, response_create_simple_serve networks=networks, start_after_create=False, ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -873,8 +905,13 @@ def test_create_with_networks(self, servers_client, response_create_simple_serve assert next_actions[0].id == 13 - def test_create_with_firewalls(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_firewalls( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server firewalls = [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -883,7 +920,7 @@ def test_create_with_firewalls(self, servers_client, response_create_simple_serv firewalls=firewalls, start_after_create=False, ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -914,9 +951,12 @@ def test_create_with_firewalls(self, servers_client, response_create_simple_serv assert next_actions[0].id == 13 def test_create_with_placement_group( - self, servers_client, response_create_simple_server + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, ): - servers_client._client.request.return_value = response_create_simple_server + request_mock.return_value = response_create_simple_server placement_group = PlacementGroup(id=1) response = servers_client.create( "my-server", @@ -926,7 +966,7 @@ def test_create_with_placement_group( placement_group=placement_group, ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers", method="POST", json={ @@ -959,10 +999,16 @@ def test_create_with_placement_group( @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, servers_client, server, response_get_actions): - servers_client._client.request.return_value = response_get_actions + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = servers_client.get_actions_list(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions", method="GET", params={} ) @@ -979,10 +1025,16 @@ def test_get_actions_list(self, servers_client, server, response_get_actions): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_update(self, servers_client, server, response_update_server): - servers_client._client.request.return_value = response_update_server + def test_update( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_update_server, + ): + request_mock.return_value = response_update_server server = servers_client.update(server, name="new-name", labels={}) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1", method="PUT", json={"name": "new-name", "labels": {}} ) @@ -992,12 +1044,16 @@ def test_update(self, servers_client, server, response_update_server): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.delete(server) - servers_client._client.request.assert_called_with( - url="/servers/1", method="DELETE" - ) + request_mock.assert_called_with(url="/servers/1", method="DELETE") assert action.id == 1 assert action.progress == 0 @@ -1005,10 +1061,16 @@ def test_delete(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_power_off(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_power_off( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.power_off(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/poweroff", method="POST" ) @@ -1018,12 +1080,16 @@ def test_power_off(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_power_on(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_power_on( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.power_on(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/poweron", method="POST" - ) + request_mock.assert_called_with(url="/servers/1/actions/poweron", method="POST") assert action.id == 1 assert action.progress == 0 @@ -1031,12 +1097,16 @@ def test_power_on(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_reboot(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_reboot( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.reboot(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/reboot", method="POST" - ) + request_mock.assert_called_with(url="/servers/1/actions/reboot", method="POST") assert action.id == 1 assert action.progress == 0 @@ -1044,12 +1114,16 @@ def test_reboot(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_reset(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_reset( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.reset(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/reset", method="POST" - ) + request_mock.assert_called_with(url="/servers/1/actions/reset", method="POST") assert action.id == 1 assert action.progress == 0 @@ -1057,10 +1131,16 @@ def test_reset(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_shutdown(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_shutdown( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.shutdown(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/shutdown", method="POST" ) @@ -1071,11 +1151,15 @@ def test_shutdown(self, servers_client, server, generic_action): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_reset_password( - self, servers_client, server, response_server_reset_password + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_reset_password, ): - servers_client._client.request.return_value = response_server_reset_password + request_mock.return_value = response_server_reset_password response = servers_client.reset_password(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/reset_password", method="POST" ) @@ -1087,13 +1171,17 @@ def test_reset_password( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_change_type_with_server_type_name( - self, servers_client, server, generic_action + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, ): - servers_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = servers_client.change_type( server, ServerType(name="cx11"), upgrade_disk=True ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/change_type", method="POST", json={"server_type": "cx11", "upgrade_disk": True}, @@ -1106,11 +1194,15 @@ def test_change_type_with_server_type_name( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_change_type_with_server_type_id( - self, servers_client, server, generic_action + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, ): - servers_client._client.request.return_value = generic_action + request_mock.return_value = generic_action action = servers_client.change_type(server, ServerType(id=1), upgrade_disk=True) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/change_type", method="POST", json={"server_type": 1, "upgrade_disk": True}, @@ -1122,19 +1214,30 @@ def test_change_type_with_server_type_id( @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_type_with_blank_server_type(self, servers_client, server): + def test_change_type_with_blank_server_type( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + ): with pytest.raises(ValueError) as e: servers_client.change_type(server, ServerType(), upgrade_disk=True) assert str(e.value) == "id or name must be set" - servers_client._client.request.assert_not_called() + request_mock.assert_not_called() @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_enable_rescue(self, servers_client, server, response_server_enable_rescue): - servers_client._client.request.return_value = response_server_enable_rescue + def test_enable_rescue( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_enable_rescue, + ): + request_mock.return_value = response_server_enable_rescue response = servers_client.enable_rescue(server, "linux64", [2323]) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/enable_rescue", method="POST", json={"type": "linux64", "ssh_keys": [2323]}, @@ -1147,10 +1250,16 @@ def test_enable_rescue(self, servers_client, server, response_server_enable_resc @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_disable_rescue(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_disable_rescue( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.disable_rescue(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/disable_rescue", method="POST" ) @@ -1160,12 +1269,18 @@ def test_disable_rescue(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_create_image(self, servers_client, server, response_server_create_image): - servers_client._client.request.return_value = response_server_create_image + def test_create_image( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_create_image, + ): + request_mock.return_value = response_server_create_image response = servers_client.create_image( server, description="my image", type="snapshot", labels={"key": "value"} ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/create_image", method="POST", json={ @@ -1182,14 +1297,20 @@ def test_create_image(self, servers_client, server, response_server_create_image @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_rebuild(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_rebuild( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action response = servers_client.rebuild( server, Image(name="ubuntu-20.04"), return_response=True, ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/rebuild", method="POST", json={"image": "ubuntu-20.04"}, @@ -1202,10 +1323,16 @@ def test_rebuild(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_enable_backup(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_enable_backup( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.enable_backup(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/enable_backup", method="POST" ) @@ -1215,10 +1342,16 @@ def test_enable_backup(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_disable_backup(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_disable_backup( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.disable_backup(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/disable_backup", method="POST" ) @@ -1228,12 +1361,18 @@ def test_disable_backup(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_attach_iso(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_attach_iso( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.attach_iso( server, Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1") ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/attach_iso", method="POST", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, @@ -1245,10 +1384,16 @@ def test_attach_iso(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_detach_iso(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_detach_iso( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.detach_iso(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/detach_iso", method="POST" ) @@ -1258,10 +1403,16 @@ def test_detach_iso(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_dns_ptr(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.change_dns_ptr(server, "1.2.3.4", "example.com") - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/change_dns_ptr", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, @@ -1273,10 +1424,16 @@ def test_change_dns_ptr(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + generic_action, + ): + request_mock.return_value = generic_action action = servers_client.change_protection(server, True, True) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/change_protection", method="POST", json={"delete": True, "rebuild": True}, @@ -1289,11 +1446,15 @@ def test_change_protection(self, servers_client, server, generic_action): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_request_console( - self, servers_client, server, response_server_request_console + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_request_console, ): - servers_client._client.request.return_value = response_server_request_console + request_mock.return_value = response_server_request_console response = servers_client.request_console(server) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/request_console", method="POST" ) @@ -1312,13 +1473,18 @@ def test_request_console( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_attach_to_network( - self, servers_client, server, network, response_attach_to_network + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_attach_to_network, ): - servers_client._client.request.return_value = response_attach_to_network + request_mock.return_value = response_attach_to_network action = servers_client.attach_to_network( server, network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/attach_to_network", method="POST", json={ @@ -1339,11 +1505,16 @@ def test_attach_to_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_detach_from_network( - self, servers_client, server, network, response_detach_from_network + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_detach_from_network, ): - servers_client._client.request.return_value = response_detach_from_network + request_mock.return_value = response_detach_from_network action = servers_client.detach_from_network(server, network) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/detach_from_network", method="POST", json={"network": 4711}, @@ -1360,13 +1531,18 @@ def test_detach_from_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_change_alias_ips( - self, servers_client, server, network, response_change_alias_ips + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_change_alias_ips, ): - servers_client._client.request.return_value = response_change_alias_ips + request_mock.return_value = response_change_alias_ips action = servers_client.change_alias_ips( server, network, ["10.0.1.2", "10.0.1.3"] ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/1/actions/change_alias_ips", method="POST", json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, @@ -1376,26 +1552,32 @@ def test_change_alias_ips( assert action.progress == 0 assert action.command == "change_alias_ips" - def test_actions_get_by_id(self, servers_client, response_get_actions): - servers_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = servers_client.actions.get_by_id(13) - servers_client._client.request.assert_called_with( - url="/servers/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/servers/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == servers_client._client.actions assert action.id == 13 assert action.command == "start_server" - def test_actions_get_list(self, servers_client, response_get_actions): - servers_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = servers_client.actions.get_list() - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/actions", method="GET", params={}, @@ -1410,11 +1592,16 @@ def test_actions_get_list(self, servers_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "start_server" - def test_actions_get_all(self, servers_client, response_get_actions): - servers_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = servers_client.actions.get_all() - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/servers/actions", method="GET", params={"page": 1, "per_page": 50}, diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index e11f5205..5bc90aee 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -56,15 +56,18 @@ def test_delete( class TestSSHKeysClient: @pytest.fixture() - def ssh_keys_client(self): - return SSHKeysClient(client=mock.MagicMock()) + def ssh_keys_client(self, client: Client): + return SSHKeysClient(client) - def test_get_by_id(self, ssh_keys_client, ssh_key_response): - ssh_keys_client._client.request.return_value = ssh_key_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key_response, + ): + request_mock.return_value = ssh_key_response ssh_key = ssh_keys_client.get_by_id(1) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys/1", method="GET" - ) + request_mock.assert_called_with(url="/ssh_keys/1", method="GET") assert ssh_key._client is ssh_keys_client assert ssh_key.id == 2323 assert ssh_key.name == "My ssh key" @@ -83,12 +86,16 @@ def test_get_by_id(self, ssh_keys_client, ssh_key_response): {}, ], ) - def test_get_list(self, ssh_keys_client, two_ssh_keys_response, params): - ssh_keys_client._client.request.return_value = two_ssh_keys_response + def test_get_list( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + two_ssh_keys_response, + params, + ): + request_mock.return_value = two_ssh_keys_response result = ssh_keys_client.get_list(**params) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params - ) + request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) ssh_keys = result.ssh_keys assert len(ssh_keys) == 2 @@ -107,14 +114,18 @@ def test_get_list(self, ssh_keys_client, two_ssh_keys_response, params): @pytest.mark.parametrize( "params", [{"name": "My ssh key", "label_selector": "label1"}, {}] ) - def test_get_all(self, ssh_keys_client, two_ssh_keys_response, params): - ssh_keys_client._client.request.return_value = two_ssh_keys_response + def test_get_all( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + two_ssh_keys_response, + params, + ): + request_mock.return_value = two_ssh_keys_response ssh_keys = ssh_keys_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params - ) + request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) assert len(ssh_keys) == 2 @@ -129,40 +140,51 @@ def test_get_all(self, ssh_keys_client, two_ssh_keys_response, params): assert ssh_keys2.id == 2324 assert ssh_keys2.name == "SSH-Key" - def test_get_by_name(self, ssh_keys_client, one_ssh_keys_response): - ssh_keys_client._client.request.return_value = one_ssh_keys_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + one_ssh_keys_response, + ): + request_mock.return_value = one_ssh_keys_response ssh_keys = ssh_keys_client.get_by_name("SSH-Key") params = {"name": "SSH-Key"} - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params - ) + request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 assert ssh_keys.name == "SSH-Key" - def test_get_by_fingerprint(self, ssh_keys_client, one_ssh_keys_response): - ssh_keys_client._client.request.return_value = one_ssh_keys_response + def test_get_by_fingerprint( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + one_ssh_keys_response, + ): + request_mock.return_value = one_ssh_keys_response ssh_keys = ssh_keys_client.get_by_fingerprint( "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" ) params = {"fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f"} - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params - ) + request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 assert ssh_keys.name == "SSH-Key" - def test_create(self, ssh_keys_client, ssh_key_response): - ssh_keys_client._client.request.return_value = ssh_key_response + def test_create( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key_response, + ): + request_mock.return_value = ssh_key_response ssh_key = ssh_keys_client.create( name="My ssh key", public_key="ssh-rsa AAAjjk76kgf...Xt" ) - ssh_keys_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/ssh_keys", method="POST", json={"name": "My ssh key", "public_key": "ssh-rsa AAAjjk76kgf...Xt"}, @@ -174,10 +196,16 @@ def test_create(self, ssh_keys_client, ssh_key_response): @pytest.mark.parametrize( "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] ) - def test_update(self, ssh_keys_client, ssh_key, response_update_ssh_key): - ssh_keys_client._client.request.return_value = response_update_ssh_key + def test_update( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key, + response_update_ssh_key, + ): + request_mock.return_value = response_update_ssh_key ssh_key = ssh_keys_client.update(ssh_key, name="New name") - ssh_keys_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/ssh_keys/1", method="PUT", json={"name": "New name"} ) @@ -187,11 +215,15 @@ def test_update(self, ssh_keys_client, ssh_key, response_update_ssh_key): @pytest.mark.parametrize( "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, ssh_keys_client, ssh_key, generic_action): - ssh_keys_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key, + generic_action, + ): + request_mock.return_value = generic_action delete_success = ssh_keys_client.delete(ssh_key) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys/1", method="DELETE" - ) + request_mock.assert_called_with(url="/ssh_keys/1", method="DELETE") assert delete_success is True diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 5662d23e..3f5865ab 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -174,15 +174,18 @@ def test_resize( class TestVolumesClient: @pytest.fixture() - def volumes_client(self): - return VolumesClient(client=mock.MagicMock()) + def volumes_client(self, client: Client): + return VolumesClient(client) - def test_get_by_id(self, volumes_client, volume_response): - volumes_client._client.request.return_value = volume_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume_response, + ): + request_mock.return_value = volume_response bound_volume = volumes_client.get_by_id(1) - volumes_client._client.request.assert_called_with( - url="/volumes/1", method="GET" - ) + request_mock.assert_called_with(url="/volumes/1", method="GET") assert bound_volume._client is volumes_client assert bound_volume.id == 1 assert bound_volume.name == "database-storage" @@ -191,12 +194,16 @@ def test_get_by_id(self, volumes_client, volume_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, volumes_client, two_volumes_response, params): - volumes_client._client.request.return_value = two_volumes_response + def test_get_list( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + two_volumes_response, + params, + ): + request_mock.return_value = two_volumes_response result = volumes_client.get_list(**params) - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params - ) + request_mock.assert_called_with(url="/volumes", method="GET", params=params) bound_volumes = result.volumes assert result.meta is not None @@ -215,15 +222,19 @@ def test_get_list(self, volumes_client, two_volumes_response, params): assert bound_volume2.name == "vault-storage" @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) - def test_get_all(self, volumes_client, two_volumes_response, params): - volumes_client._client.request.return_value = two_volumes_response + def test_get_all( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + two_volumes_response, + params, + ): + request_mock.return_value = two_volumes_response bound_volumes = volumes_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params - ) + request_mock.assert_called_with(url="/volumes", method="GET", params=params) assert len(bound_volumes) == 2 @@ -238,22 +249,30 @@ def test_get_all(self, volumes_client, two_volumes_response, params): assert bound_volume2.id == 2 assert bound_volume2.name == "vault-storage" - def test_get_by_name(self, volumes_client, one_volumes_response): - volumes_client._client.request.return_value = one_volumes_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + one_volumes_response, + ): + request_mock.return_value = one_volumes_response bound_volume = volumes_client.get_by_name("database-storage") params = {"name": "database-storage"} - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params - ) + request_mock.assert_called_with(url="/volumes", method="GET", params=params) assert bound_volume._client is volumes_client assert bound_volume.id == 1 assert bound_volume.name == "database-storage" - def test_create_with_location(self, volumes_client, volume_create_response): - volumes_client._client.request.return_value = volume_create_response + def test_create_with_location( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume_create_response, + ): + request_mock.return_value = volume_create_response response = volumes_client.create( 100, "database-storage", @@ -261,7 +280,7 @@ def test_create_with_location(self, volumes_client, volume_create_response): automount=False, format="xfs", ) - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes", method="POST", json={ @@ -287,12 +306,18 @@ def test_create_with_location(self, volumes_client, volume_create_response): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_create_with_server(self, volumes_client, server, volume_create_response): - volumes_client._client.request.return_value = volume_create_response + def test_create_with_server( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + server, + volume_create_response, + ): + request_mock.return_value = volume_create_response volumes_client.create( 100, "database-storage", server=server, automount=False, format="xfs" ) - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes", method="POST", json={ @@ -304,34 +329,48 @@ def test_create_with_server(self, volumes_client, server, volume_create_response }, ) - def test_create_negative_size(self, volumes_client): + def test_create_negative_size( + self, + request_mock: mock.MagicMock, + volumes_client, + ): with pytest.raises(ValueError) as e: volumes_client.create( -100, "database-storage", location=Location(name="location") ) assert str(e.value) == "size must be greater than 0" - volumes_client._client.request.assert_not_called() + request_mock.assert_not_called() @pytest.mark.parametrize( "location,server", [(None, None), ("location", Server(id=1))] ) def test_create_wrong_location_server_combination( - self, volumes_client, location, server + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + location, + server, ): with pytest.raises(ValueError) as e: volumes_client.create( 100, "database-storage", location=location, server=server ) assert str(e.value) == "only one of server or location must be provided" - volumes_client._client.request.assert_not_called() + request_mock.assert_not_called() @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, volumes_client, volume, response_get_actions): - volumes_client._client.request.return_value = response_get_actions + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = volumes_client.get_actions_list(volume, sort="id") - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/1/actions", method="GET", params={"sort": "id"} ) @@ -346,10 +385,16 @@ def test_get_actions_list(self, volumes_client, volume, response_get_actions): @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_update(self, volumes_client, volume, response_update_volume): - volumes_client._client.request.return_value = response_update_volume + def test_update( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + response_update_volume, + ): + request_mock.return_value = response_update_volume volume = volumes_client.update(volume, name="new-name") - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/1", method="PUT", json={"name": "new-name"} ) @@ -359,10 +404,16 @@ def test_update(self, volumes_client, volume, response_update_volume): @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + generic_action, + ): + request_mock.return_value = generic_action action = volumes_client.change_protection(volume, True) - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/1/actions/change_protection", method="POST", json={"delete": True}, @@ -374,12 +425,16 @@ def test_change_protection(self, volumes_client, volume, generic_action): @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + generic_action, + ): + request_mock.return_value = generic_action delete_success = volumes_client.delete(volume) - volumes_client._client.request.assert_called_with( - url="/volumes/1", method="DELETE" - ) + request_mock.assert_called_with(url="/volumes/1", method="DELETE") assert delete_success is True @@ -393,10 +448,17 @@ def test_delete(self, volumes_client, volume, generic_action): ), ], ) - def test_attach(self, volumes_client, server, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_attach( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + server, + volume, + generic_action, + ): + request_mock.return_value = generic_action action = volumes_client.attach(volume, server) - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/12/actions/attach", method="POST", json={"server": 1} ) assert action.id == 1 @@ -405,47 +467,63 @@ def test_attach(self, volumes_client, server, volume, generic_action): @pytest.mark.parametrize( "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] ) - def test_detach(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_detach( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + generic_action, + ): + request_mock.return_value = generic_action action = volumes_client.detach(volume) - volumes_client._client.request.assert_called_with( - url="/volumes/12/actions/detach", method="POST" - ) + request_mock.assert_called_with(url="/volumes/12/actions/detach", method="POST") assert action.id == 1 assert action.progress == 0 @pytest.mark.parametrize( "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] ) - def test_resize(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_resize( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + generic_action, + ): + request_mock.return_value = generic_action action = volumes_client.resize(volume, 50) - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/12/actions/resize", method="POST", json={"size": 50} ) assert action.id == 1 assert action.progress == 0 - def test_actions_get_by_id(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } + def test_actions_get_by_id( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + response_get_actions, + ): + request_mock.return_value = {"action": response_get_actions["actions"][0]} action = volumes_client.actions.get_by_id(13) - volumes_client._client.request.assert_called_with( - url="/volumes/actions/13", method="GET" - ) + request_mock.assert_called_with(url="/volumes/actions/13", method="GET") assert isinstance(action, BoundAction) assert action._client == volumes_client._client.actions assert action.id == 13 assert action.command == "attach_volume" - def test_actions_get_list(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = response_get_actions + def test_actions_get_list( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions result = volumes_client.actions.get_list() - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/actions", method="GET", params={}, @@ -460,11 +538,16 @@ def test_actions_get_list(self, volumes_client, response_get_actions): assert actions[0].id == 13 assert actions[0].command == "attach_volume" - def test_actions_get_all(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = response_get_actions + def test_actions_get_all( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + response_get_actions, + ): + request_mock.return_value = response_get_actions actions = volumes_client.actions.get_all() - volumes_client._client.request.assert_called_with( + request_mock.assert_called_with( url="/volumes/actions", method="GET", params={"page": 1, "per_page": 50}, From c3cd1c9e20fc950d5d979934c9ede906a9cbda89 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 14 Aug 2025 15:57:01 +0200 Subject: [PATCH 083/213] fix: rename `ClientEntityBase` to `ResourceClientBase` (#532) This rename will reduce confusion in the future when reading the code. The `ClientEntity` name does not refer to anything. This is scoped as a fix because the class is public. --- hcloud/actions/client.py | 4 ++-- hcloud/certificates/client.py | 4 ++-- hcloud/core/__init__.py | 5 +++-- hcloud/core/client.py | 23 +++++++++++++++++++++-- hcloud/datacenters/client.py | 4 ++-- hcloud/firewalls/client.py | 4 ++-- hcloud/floating_ips/client.py | 4 ++-- hcloud/images/client.py | 4 ++-- hcloud/isos/client.py | 4 ++-- hcloud/load_balancer_types/client.py | 4 ++-- hcloud/load_balancers/client.py | 4 ++-- hcloud/locations/client.py | 4 ++-- hcloud/networks/client.py | 4 ++-- hcloud/placement_groups/client.py | 4 ++-- hcloud/primary_ips/client.py | 4 ++-- hcloud/server_types/client.py | 4 ++-- hcloud/servers/client.py | 4 ++-- hcloud/ssh_keys/client.py | 4 ++-- hcloud/volumes/client.py | 4 ++-- tests/unit/core/test_client.py | 8 ++++---- 20 files changed, 62 insertions(+), 42 deletions(-) diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index 7ec192c8..e9033323 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -4,7 +4,7 @@ import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Action, ActionFailedException, ActionTimeoutException if TYPE_CHECKING: @@ -50,7 +50,7 @@ class ActionsPageResult(NamedTuple): meta: Meta -class ResourceActionsClient(ClientEntityBase): +class ResourceActionsClient(ResourceClientBase): _resource: str def __init__(self, client: Client, resource: str | None): diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index cb1aef58..06423995 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( Certificate, CreateManagedCertificateResponse, @@ -103,7 +103,7 @@ class CertificatesPageResult(NamedTuple): meta: Meta -class CertificatesClient(ClientEntityBase): +class CertificatesClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/core/__init__.py b/hcloud/core/__init__.py index b553546a..4fb66e86 100644 --- a/hcloud/core/__init__.py +++ b/hcloud/core/__init__.py @@ -1,13 +1,14 @@ from __future__ import annotations -from .client import BoundModelBase, ClientEntityBase +from .client import BoundModelBase, ClientEntityBase, ResourceClientBase from .domain import BaseDomain, DomainIdentityMixin, Meta, Pagination __all__ = [ + "BaseDomain", "BoundModelBase", "ClientEntityBase", - "BaseDomain", "DomainIdentityMixin", "Meta", "Pagination", + "ResourceClientBase", ] diff --git a/hcloud/core/client.py b/hcloud/core/client.py index 0b86c837..9b52f368 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,12 +1,13 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: from .._client import Client -class ClientEntityBase: +class ResourceClientBase: _client: Client max_per_page: int = 50 @@ -50,6 +51,24 @@ def _get_first_by(self, **kwargs): # type: ignore[no-untyped-def] return entities[0] if entities else None +class ClientEntityBase(ResourceClientBase): + """ + Kept for backward compatibility. + + .. deprecated:: 2.6.0 + Use :class:``hcloud.core.client.ResourceClientBase`` instead. + """ + + def __init__(self, client: Client): + warnings.warn( + "The 'hcloud.core.client.ClientEntityBase' class is deprecated, please use the " + "'hcloud.core.client.ResourceClientBase' class instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(client) + + class BoundModelBase: """Bound Model Base""" @@ -57,7 +76,7 @@ class BoundModelBase: def __init__( self, - client: ClientEntityBase, + client: ResourceClientBase, data: dict, complete: bool = True, ): diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 935a7c77..4a1b0434 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from ..server_types import BoundServerType from .domain import Datacenter, DatacenterServerTypes @@ -55,7 +55,7 @@ class DatacentersPageResult(NamedTuple): meta: Meta -class DatacentersClient(ClientEntityBase): +class DatacentersClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundDatacenter: diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index ae365a4b..2157e8f5 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( CreateFirewallResponse, Firewall, @@ -183,7 +183,7 @@ class FirewallsPageResult(NamedTuple): meta: Meta -class FirewallsClient(ClientEntityBase): +class FirewallsClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 9306ac2f..824cfdbb 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateFloatingIPResponse, FloatingIP @@ -139,7 +139,7 @@ class FloatingIPsPageResult(NamedTuple): meta: Meta -class FloatingIPsClient(ClientEntityBase): +class FloatingIPsClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/images/client.py b/hcloud/images/client.py index 36c59e06..c7af92d9 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Image if TYPE_CHECKING: @@ -112,7 +112,7 @@ class ImagesPageResult(NamedTuple): meta: Meta -class ImagesClient(ClientEntityBase): +class ImagesClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index 31620d8f..abb28617 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Iso if TYPE_CHECKING: @@ -20,7 +20,7 @@ class IsosPageResult(NamedTuple): meta: Meta -class IsosClient(ClientEntityBase): +class IsosClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundIso: diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 5f332c6e..3691e419 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import LoadBalancerType if TYPE_CHECKING: @@ -20,7 +20,7 @@ class LoadBalancerTypesPageResult(NamedTuple): meta: Meta -class LoadBalancerTypesClient(ClientEntityBase): +class LoadBalancerTypesClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundLoadBalancerType: diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 2de1c2d7..5553352f 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -7,7 +7,7 @@ from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..certificates import BoundCertificate -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation from ..metrics import Metrics @@ -369,7 +369,7 @@ class LoadBalancersPageResult(NamedTuple): meta: Meta -class LoadBalancersClient(ClientEntityBase): +class LoadBalancersClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 411a184d..6adb980a 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Location if TYPE_CHECKING: @@ -20,7 +20,7 @@ class LocationsPageResult(NamedTuple): meta: Meta -class LocationsClient(ClientEntityBase): +class LocationsClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundLocation: diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index c446ae6b..b8248d7a 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Network, NetworkRoute, NetworkSubnet if TYPE_CHECKING: @@ -166,7 +166,7 @@ class NetworksPageResult(NamedTuple): meta: Meta -class NetworksClient(ClientEntityBase): +class NetworksClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index 0c53724f..a5524afe 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import BoundAction -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePlacementGroupResponse, PlacementGroup if TYPE_CHECKING: @@ -43,7 +43,7 @@ class PlacementGroupsPageResult(NamedTuple): meta: Meta -class PlacementGroupsClient(ClientEntityBase): +class PlacementGroupsClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundPlacementGroup: diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 818737c5..b3985e56 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePrimaryIPResponse, PrimaryIP if TYPE_CHECKING: @@ -97,7 +97,7 @@ class PrimaryIPsPageResult(NamedTuple): meta: Meta -class PrimaryIPsClient(ClientEntityBase): +class PrimaryIPsClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 6901d995..1f1a7de3 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ServerType if TYPE_CHECKING: @@ -20,7 +20,7 @@ class ServerTypesPageResult(NamedTuple): meta: Meta -class ServerTypesClient(ClientEntityBase): +class ServerTypesClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundServerType: diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 397b0bf2..89c0c4eb 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -6,7 +6,7 @@ from dateutil.parser import isoparse from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..datacenters import BoundDatacenter from ..firewalls import BoundFirewall from ..floating_ips import BoundFloatingIP @@ -482,7 +482,7 @@ class ServersPageResult(NamedTuple): meta: Meta -class ServersClient(ClientEntityBase): +class ServersClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index 12064ea9..a68b5153 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import SSHKey if TYPE_CHECKING: @@ -41,7 +41,7 @@ class SSHKeysPageResult(NamedTuple): meta: Meta -class SSHKeysClient(ClientEntityBase): +class SSHKeysClient(ResourceClientBase): _client: Client def get_by_id(self, id: int) -> BoundSSHKey: diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 371a8bd1..15a71c4d 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateVolumeResponse, Volume @@ -135,7 +135,7 @@ class VolumesPageResult(NamedTuple): meta: Meta -class VolumesClient(ClientEntityBase): +class VolumesClient(ResourceClientBase): _client: Client actions: ResourceActionsClient diff --git a/tests/unit/core/test_client.py b/tests/unit/core/test_client.py index 4a6542eb..72e90879 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -6,7 +6,7 @@ import pytest from hcloud.actions import ActionsPageResult -from hcloud.core import BaseDomain, BoundModelBase, ClientEntityBase, Meta +from hcloud.core import BaseDomain, BoundModelBase, Meta, ResourceClientBase class TestBoundModelBase: @@ -99,7 +99,7 @@ def test_equality(self, bound_model_class, client): assert bound_model_a != bound_model_b.data_model -class TestClientEntityBase: +class TestResourceClientBase: @pytest.fixture() def client_class_constructor(self): def constructor(json_content_function): @@ -107,7 +107,7 @@ class CandiesPageResult(NamedTuple): candies: list[Any] meta: Meta - class CandiesClient(ClientEntityBase): + class CandiesClient(ResourceClientBase): def get_list(self, status=None, page=None, per_page=None): json_content = json_content_function(page) results = [ @@ -122,7 +122,7 @@ def get_list(self, status=None, page=None, per_page=None): @pytest.fixture() def client_class_with_actions_constructor(self): def constructor(json_content_function): - class CandiesClient(ClientEntityBase): + class CandiesClient(ResourceClientBase): def get_actions_list(self, status, page=None, per_page=None): json_content = json_content_function(page) results = [ From 2b4773eee32adbb12eebf40e167659e78cd2a784 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 14 Aug 2025 16:37:36 +0200 Subject: [PATCH 084/213] refactor: add BoundModelBase model property type (#539) Helps the type checkers to detect mistakes. --- hcloud/core/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index 9b52f368..b2f10ee1 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from .._client import Client + from .domain import BaseDomain class ResourceClientBase: @@ -72,7 +73,7 @@ def __init__(self, client: Client): class BoundModelBase: """Bound Model Base""" - model: Any + model: type[BaseDomain] def __init__( self, From 4698975ae444745f24199ea97f5135fd015cb78e Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 15 Aug 2025 11:39:10 +0200 Subject: [PATCH 085/213] refactor: split top level client from base client (#534) Split the top level client used to gather all resource client in a single class, from the base client actually doing the requests to the API. This allows us for example to swap or modify the base client in a resource client, that might need a different base client (session, endpoint, headers, ...). --- hcloud/_client.py | 135 ++++--- hcloud/actions/client.py | 16 +- hcloud/certificates/client.py | 7 +- hcloud/core/client.py | 13 +- hcloud/datacenters/client.py | 14 +- hcloud/firewalls/client.py | 15 +- hcloud/floating_ips/client.py | 17 +- hcloud/images/client.py | 9 +- hcloud/isos/client.py | 6 +- hcloud/load_balancer_types/client.py | 6 +- hcloud/load_balancers/client.py | 41 +- hcloud/locations/client.py | 6 +- hcloud/networks/client.py | 17 +- hcloud/placement_groups/client.py | 8 +- hcloud/primary_ips/client.py | 13 +- hcloud/server_types/client.py | 6 +- hcloud/servers/client.py | 79 ++-- hcloud/ssh_keys/client.py | 6 +- hcloud/volumes/client.py | 19 +- tests/unit/actions/test_client.py | 24 +- tests/unit/certificates/test_client.py | 6 +- tests/unit/conftest.py | 15 +- tests/unit/firewalls/test_client.py | 8 +- tests/unit/floating_ips/test_client.py | 8 +- tests/unit/images/test_client.py | 8 +- tests/unit/load_balancers/test_client.py | 6 +- tests/unit/networks/test_client.py | 8 +- tests/unit/primary_ips/test_client.py | 6 +- tests/unit/servers/test_client.py | 36 +- tests/unit/test_client.py | 461 +++++++++++++---------- tests/unit/volumes/test_client.py | 8 +- 31 files changed, 552 insertions(+), 475 deletions(-) diff --git a/hcloud/_client.py b/hcloud/_client.py index a466b744..0b67a4fe 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -78,6 +78,25 @@ def func(retries: int) -> float: return func +def _build_user_agent( + application_name: str | None, + application_version: str | None, +) -> str: + """Build the user agent of the hcloud-python instance with the user application name (if specified) + + :return: The user agent of this hcloud-python instance + """ + parts = [] + for name, version in [ + (application_name, application_version), + ("hcloud-python", __version__), + ]: + if name is not None: + parts.append(name if version is None else f"{name}/{version}") + + return " ".join(parts) + + class Client: """ Client for the Hetzner Cloud API. @@ -112,14 +131,6 @@ class Client: breaking changes. """ - _version = __version__ - __user_agent_prefix = "hcloud-python" - - _retry_interval = staticmethod( - exponential_backoff_function(base=1.0, multiplier=2, cap=60.0, jitter=True) - ) - _retry_max_retries = 5 - def __init__( self, token: str, @@ -143,18 +154,15 @@ def __init__( Max retries before timeout when polling actions from the API. :param timeout: Requests timeout in seconds """ - self.token = token - self._api_endpoint = api_endpoint - self._application_name = application_name - self._application_version = application_version - self._requests_session = requests.Session() - self._requests_timeout = timeout - - if isinstance(poll_interval, (int, float)): - self._poll_interval_func = constant_backoff_function(poll_interval) - else: - self._poll_interval_func = poll_interval - self._poll_max_retries = poll_max_retries + self._client = ClientBase( + token=token, + endpoint=api_endpoint, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) self.datacenters = DatacentersClient(self) """DatacentersClient Instance @@ -246,27 +254,58 @@ def __init__( :type: :class:`PlacementGroupsClient ` """ - def _get_user_agent(self) -> str: - """Get the user agent of the hcloud-python instance with the user application name (if specified) + def request( # type: ignore[no-untyped-def] + self, + method: str, + url: str, + **kwargs, + ) -> dict: + """Perform a request to the Hetzner Cloud API. - :return: The user agent of this hcloud-python instance + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. """ - user_agents = [] - for name, version in [ - (self._application_name, self._application_version), - (self.__user_agent_prefix, self._version), - ]: - if name is not None: - user_agents.append(name if version is None else f"{name}/{version}") - - return " ".join(user_agents) - - def _get_headers(self) -> dict: - headers = { - "User-Agent": self._get_user_agent(), - "Authorization": f"Bearer {self.token}", + return self._client.request(method, url, **kwargs) + + +class ClientBase: + def __init__( + self, + token: str, + *, + endpoint: str, + application_name: str | None = None, + application_version: str | None = None, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, + timeout: float | tuple[float, float] | None = None, + ): + self._token = token + self._endpoint = endpoint + + self._user_agent = _build_user_agent(application_name, application_version) + self._headers = { + "User-Agent": self._user_agent, + "Authorization": f"Bearer {self._token}", + "Accept": "application/json", } - return headers + + if isinstance(poll_interval, (int, float)): + poll_interval_func = constant_backoff_function(poll_interval) + else: + poll_interval_func = poll_interval + + self._poll_interval_func = poll_interval_func + self._poll_max_retries = poll_max_retries + + self._retry_interval_func = exponential_backoff_function( + base=1.0, multiplier=2, cap=60.0, jitter=True + ) + self._retry_max_retries = 5 + + self._timeout = timeout + self._session = requests.Session() def request( # type: ignore[no-untyped-def] self, @@ -274,22 +313,22 @@ def request( # type: ignore[no-untyped-def] url: str, **kwargs, ) -> dict: - """Perform a request to the Hetzner Cloud API, wrapper around requests.request + """Perform a request to the provided URL. - :param method: HTTP Method to perform the Request - :param url: URL of the Endpoint - :param timeout: Requests timeout in seconds + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. :return: Response """ - kwargs.setdefault("timeout", self._requests_timeout) + kwargs.setdefault("timeout", self._timeout) - url = self._api_endpoint + url - headers = self._get_headers() + url = self._endpoint + url + headers = self._headers retries = 0 while True: try: - response = self._requests_session.request( + response = self._session.request( method=method, url=url, headers=headers, @@ -298,13 +337,13 @@ def request( # type: ignore[no-untyped-def] return self._read_response(response) except APIException as exception: if retries < self._retry_max_retries and self._retry_policy(exception): - time.sleep(self._retry_interval(retries)) + time.sleep(self._retry_interval_func(retries)) retries += 1 continue raise except requests.exceptions.Timeout: if retries < self._retry_max_retries: - time.sleep(self._retry_interval(retries)) + time.sleep(self._retry_interval_func(retries)) retries += 1 continue raise diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index e9033323..a0c0cc49 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -53,8 +53,16 @@ class ActionsPageResult(NamedTuple): class ResourceActionsClient(ResourceClientBase): _resource: str - def __init__(self, client: Client, resource: str | None): - super().__init__(client) + def __init__(self, client: ResourceClientBase | Client, resource: str | None): + if isinstance(client, ResourceClientBase): + super().__init__(client._parent) + # Use the same base client as the the resource base client. Allows us to + # choose the base client outside of the ResourceActionsClient. + self._client = client._client + else: + # Backward compatibility, defaults to the parent ("top level") base client (`_client`). + super().__init__(client) + self._resource = resource or "" def get_by_id(self, id: int) -> BoundAction: @@ -67,7 +75,7 @@ def get_by_id(self, id: int) -> BoundAction: url=f"{self._resource}/actions/{id}", method="GET", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def get_list( self, @@ -104,7 +112,7 @@ def get_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 06423995..72856ba8 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -104,7 +104,6 @@ class CertificatesPageResult(NamedTuple): class CertificatesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Certificates scoped actions client @@ -248,7 +247,7 @@ def create_managed( response = self._client.request(url="/certificates", method="POST", json=data) return CreateManagedCertificateResponse( certificate=BoundCertificate(self, response["certificate"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -328,7 +327,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -368,4 +367,4 @@ def retry_issuance( url=f"/certificates/{certificate.id}/actions/retry", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index b2f10ee1..ab91611e 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -4,21 +4,20 @@ from typing import TYPE_CHECKING, Any, Callable if TYPE_CHECKING: - from .._client import Client + from .._client import Client, ClientBase from .domain import BaseDomain class ResourceClientBase: - _client: Client + _parent: Client + _client: ClientBase max_per_page: int = 50 def __init__(self, client: Client): - """ - :param client: Client - :return self - """ - self._client = client + self._parent = client + # Use the parent "default" base client. + self._client = client._client def _iter_pages( # type: ignore[no-untyped-def] self, diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 4a1b0434..0d793cf3 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,15 +1,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import 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 - class BoundDatacenter(BoundModelBase, Datacenter): _client: DatacentersClient @@ -19,25 +16,25 @@ class BoundDatacenter(BoundModelBase, Datacenter): def __init__(self, client: DatacentersClient, data: dict): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) server_types = data.get("server_types") if server_types is not None: available = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available"] ] supported = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["supported"] ] available_for_migration = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available_for_migration"] ] @@ -56,7 +53,6 @@ class DatacentersPageResult(NamedTuple): class DatacentersClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 2157e8f5..94b4cbd6 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -52,7 +52,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): type=resource["type"], server=( BoundServer( - client._client.servers, + client._parent.servers, resource.get("server"), complete=False, ) @@ -68,7 +68,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): FirewallResource( type=firewall_resource["type"], server=BoundServer( - client._client.servers, + client._parent.servers, firewall_resource["server"], complete=False, ), @@ -184,7 +184,6 @@ class FirewallsPageResult(NamedTuple): class FirewallsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Firewalls scoped actions client @@ -232,7 +231,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -378,7 +377,7 @@ def create( actions = [] if response.get("actions") is not None: actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -448,7 +447,7 @@ def set_rules( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -472,7 +471,7 @@ def apply_to_resources( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -496,6 +495,6 @@ def remove_from_resources( json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 824cfdbb..c1ce8403 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -25,13 +25,13 @@ def __init__(self, client: FloatingIPsClient, data: dict, complete: bool = True) server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) home_location = data.get("home_location") if home_location is not None: data["home_location"] = BoundLocation( - client._client.locations, home_location + client._parent.locations, home_location ) super().__init__(client, data, complete) @@ -140,7 +140,6 @@ class FloatingIPsPageResult(NamedTuple): class FloatingIPsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Floating IPs scoped actions client @@ -188,7 +187,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -329,7 +328,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreateFloatingIPResponse( floating_ip=BoundFloatingIP(self, response["floating_ip"]), action=action @@ -403,7 +402,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -422,7 +421,7 @@ def assign( method="POST", json={"server": server.id}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: """Unassigns a Floating IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -434,7 +433,7 @@ def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: url=f"/floating_ips/{floating_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -456,4 +455,4 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/images/client.py b/hcloud/images/client.py index c7af92d9..ea4e66dd 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -23,12 +23,12 @@ def __init__(self, client: ImagesClient, data: dict): created_from = data.get("created_from") if created_from is not None: data["created_from"] = BoundServer( - client._client.servers, created_from, complete=False + client._parent.servers, created_from, complete=False ) bound_to = data.get("bound_to") if bound_to is not None: data["bound_to"] = BoundServer( - client._client.servers, {"id": bound_to}, complete=False + client._parent.servers, {"id": bound_to}, complete=False ) super().__init__(client, data) @@ -113,7 +113,6 @@ class ImagesPageResult(NamedTuple): class ImagesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Images scoped actions client @@ -161,7 +160,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -406,4 +405,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index abb28617..25c20c34 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Iso -if TYPE_CHECKING: - from .._client import Client - class BoundIso(BoundModelBase, Iso): _client: IsosClient @@ -21,7 +18,6 @@ class IsosPageResult(NamedTuple): class IsosClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundIso: """Get a specific ISO by its id diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 3691e419..a237cb11 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import LoadBalancerType -if TYPE_CHECKING: - from .._client import Client - class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): _client: LoadBalancerTypesClient @@ -21,7 +18,6 @@ class LoadBalancerTypesPageResult(NamedTuple): class LoadBalancerTypesClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundLoadBalancerType: """Returns a specific Load Balancer Type. diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 5553352f..7e42a325 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -64,7 +64,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -81,7 +81,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru tmp_target = LoadBalancerTarget(type=target["type"]) if target["type"] == "server": tmp_target.server = BoundServer( - client._client.servers, data=target["server"], complete=False + client._parent.servers, data=target["server"], complete=False ) tmp_target.use_private_ip = target["use_private_ip"] elif target["type"] == "label_selector": @@ -124,7 +124,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru ) tmp_service.http.certificates = [ BoundCertificate( - client._client.certificates, + client._parent.certificates, {"id": certificate}, complete=False, ) @@ -152,12 +152,12 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru load_balancer_type = data.get("load_balancer_type") if load_balancer_type is not None: data["load_balancer_type"] = BoundLoadBalancerType( - client._client.load_balancer_types, load_balancer_type + client._parent.load_balancer_types, load_balancer_type ) location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) super().__init__(client, data, complete) @@ -370,7 +370,6 @@ class LoadBalancersPageResult(NamedTuple): class LoadBalancersClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Load Balancers scoped actions client @@ -519,7 +518,7 @@ def create( return CreateLoadBalancerResponse( load_balancer=BoundLoadBalancer(self, response["load_balancer"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -638,7 +637,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -684,7 +683,7 @@ def add_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def update_service( self, @@ -704,7 +703,7 @@ def update_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_service( self, @@ -725,7 +724,7 @@ def delete_service( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_target( self, @@ -746,7 +745,7 @@ def add_target( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_target( self, @@ -769,7 +768,7 @@ def remove_target( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_algorithm( self, @@ -790,7 +789,7 @@ def change_algorithm( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -812,7 +811,7 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -835,7 +834,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_to_network( self, @@ -860,7 +859,7 @@ def attach_to_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -879,7 +878,7 @@ def detach_from_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_public_interface( self, @@ -896,7 +895,7 @@ def enable_public_interface( url=f"/load_balancers/{load_balancer.id}/actions/enable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_public_interface( self, @@ -913,7 +912,7 @@ def disable_public_interface( url=f"/load_balancers/{load_balancer.id}/actions/disable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_type( self, @@ -933,4 +932,4 @@ def change_type( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 6adb980a..11d83ca6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Location -if TYPE_CHECKING: - from .._client import Client - class BoundLocation(BoundModelBase, Location): _client: LocationsClient @@ -21,7 +18,6 @@ class LocationsPageResult(NamedTuple): class LocationsClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundLocation: """Get a specific location by its ID. diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index b8248d7a..23059b89 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -32,7 +32,7 @@ def __init__(self, client: NetworksClient, data: dict, complete: bool = True): servers = data.get("servers", []) if servers is not None: servers = [ - BoundServer(client._client.servers, {"id": server}, complete=False) + BoundServer(client._parent.servers, {"id": server}, complete=False) for server in servers ] data["servers"] = servers @@ -167,7 +167,6 @@ class NetworksPageResult(NamedTuple): class NetworksClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Networks scoped actions client @@ -387,7 +386,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -440,7 +439,7 @@ def add_subnet( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_subnet( self, @@ -461,7 +460,7 @@ def delete_subnet( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_route( self, @@ -485,7 +484,7 @@ def add_route( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_route( self, @@ -509,7 +508,7 @@ def delete_route( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_ip_range( self, @@ -530,7 +529,7 @@ def change_ip_range( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -553,4 +552,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index a5524afe..48894326 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -1,14 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..actions import BoundAction from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePlacementGroupResponse, PlacementGroup -if TYPE_CHECKING: - from .._client import Client - class BoundPlacementGroup(BoundModelBase, PlacementGroup): _client: PlacementGroupsClient @@ -44,7 +41,6 @@ class PlacementGroupsPageResult(NamedTuple): class PlacementGroupsClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundPlacementGroup: """Returns a specific Placement Group object @@ -164,7 +160,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePlacementGroupResponse( placement_group=BoundPlacementGroup(self, response["placement_group"]), diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index b3985e56..7bf4f4cc 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -22,7 +22,7 @@ def __init__(self, client: PrimaryIPsClient, data: dict, complete: bool = True): datacenter = data.get("datacenter", {}) if datacenter: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) super().__init__(client, data, complete) @@ -98,7 +98,6 @@ class PrimaryIPsPageResult(NamedTuple): class PrimaryIPsClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Primary IPs scoped actions client @@ -225,7 +224,7 @@ def create( action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePrimaryIPResponse( primary_ip=BoundPrimaryIP(self, response["primary_ip"]), action=action @@ -299,7 +298,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -321,7 +320,7 @@ def assign( method="POST", json={"assignee_id": assignee_id, "assignee_type": assignee_type}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: """Unassigns a Primary IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -333,7 +332,7 @@ def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: url=f"/primary_ips/{primary_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -355,4 +354,4 @@ def change_dns_ptr( method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 1f1a7de3..3f9df22c 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ServerType -if TYPE_CHECKING: - from .._client import Client - class BoundServerType(BoundModelBase, ServerType): _client: ServerTypesClient @@ -21,7 +18,6 @@ class ServerTypesPageResult(NamedTuple): class ServerTypesClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundServerType: """Returns a specific Server Type. diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 89c0c4eb..b4f7e050 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -57,28 +57,28 @@ class BoundServer(BoundModelBase, Server): def __init__(self, client: ServersClient, data: dict, complete: bool = True): datacenter = data.get("datacenter") if datacenter is not None: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) volumes = data.get("volumes", []) if volumes: volumes = [ - BoundVolume(client._client.volumes, {"id": volume}, complete=False) + BoundVolume(client._parent.volumes, {"id": volume}, complete=False) for volume in volumes ] data["volumes"] = volumes image = data.get("image", None) if image is not None: - data["image"] = BoundImage(client._client.images, image) + data["image"] = BoundImage(client._parent.images, image) iso = data.get("iso", None) if iso is not None: - data["iso"] = BoundIso(client._client.isos, iso) + data["iso"] = BoundIso(client._parent.isos, iso) server_type = data.get("server_type") if server_type is not None: data["server_type"] = BoundServerType( - client._client.server_types, server_type + client._parent.server_types, server_type ) public_net = data.get("public_net") @@ -90,7 +90,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv4_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv4"]["id"]}, complete=False, ) @@ -104,7 +104,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv6_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv6"]["id"]}, complete=False, ) @@ -113,14 +113,14 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) floating_ips = [ BoundFloatingIP( - client._client.floating_ips, {"id": floating_ip}, complete=False + client._parent.floating_ips, {"id": floating_ip}, complete=False ) for floating_ip in public_net["floating_ips"] ] firewalls = [ PublicNetworkFirewall( BoundFirewall( - client._client.firewalls, {"id": firewall["id"]}, complete=False + client._parent.firewalls, {"id": firewall["id"]}, complete=False ), status=firewall["status"], ) @@ -143,7 +143,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -158,7 +158,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): placement_group = data.get("placement_group") if placement_group: placement_group = BoundPlacementGroup( - client._client.placement_groups, placement_group + client._parent.placement_groups, placement_group ) data["placement_group"] = placement_group @@ -483,7 +483,6 @@ class ServersPageResult(NamedTuple): class ServersClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Servers scoped actions client @@ -669,9 +668,9 @@ def create( result = CreateServerResponse( server=BoundServer(self, response["server"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], root_password=response["root_password"], @@ -715,7 +714,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -816,7 +815,7 @@ def delete(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request(url=f"/servers/{server.id}", method="DELETE") - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def power_off(self, server: Server | BoundServer) -> BoundAction: """Cuts power to the server. This forcefully stops it without giving the server operating system time to gracefully stop @@ -828,7 +827,7 @@ def power_off(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/poweroff", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def power_on(self, server: Server | BoundServer) -> BoundAction: """Starts a server by turning its power on. @@ -840,7 +839,7 @@ def power_on(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/poweron", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reboot(self, server: Server | BoundServer) -> BoundAction: """Reboots a server gracefully by sending an ACPI request. @@ -852,7 +851,7 @@ def reboot(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/reboot", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset(self, server: Server | BoundServer) -> BoundAction: """Cuts power to a server and starts it again. @@ -864,7 +863,7 @@ def reset(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/reset", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def shutdown(self, server: Server | BoundServer) -> BoundAction: """Shuts down a server gracefully by sending an ACPI shutdown request. @@ -876,7 +875,7 @@ def shutdown(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/shutdown", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: """Resets the root password. Only works for Linux systems that are running the qemu guest agent. @@ -889,7 +888,7 @@ def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: method="POST", ) return ResetPasswordResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -917,7 +916,7 @@ def change_type( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_rescue( self, @@ -945,7 +944,7 @@ def enable_rescue( json=data, ) return EnableRescueResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -959,7 +958,7 @@ def disable_rescue(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/disable_rescue", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def create_image( self, @@ -996,8 +995,8 @@ def create_image( json=data, ) return CreateImageResponse( - action=BoundAction(self._client.actions, response["action"]), - image=BoundImage(self._client.images, response["image"]), + action=BoundAction(self._parent.actions, response["action"]), + image=BoundImage(self._parent.images, response["image"]), ) def rebuild( @@ -1020,7 +1019,7 @@ def rebuild( ) return RebuildResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response.get("root_password"), ) @@ -1034,7 +1033,7 @@ def enable_backup(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/enable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_backup(self, server: Server | BoundServer) -> BoundAction: """Disables the automatic backup option and deletes all existing Backups for a Server. @@ -1046,7 +1045,7 @@ def disable_backup(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/disable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_iso( self, @@ -1065,7 +1064,7 @@ def attach_iso( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_iso(self, server: Server | BoundServer) -> BoundAction: """Detaches an ISO from a server. @@ -1077,7 +1076,7 @@ def detach_iso(self, server: Server | BoundServer) -> BoundAction: url=f"/servers/{server.id}/actions/detach_iso", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -1100,7 +1099,7 @@ def change_dns_ptr( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -1128,7 +1127,7 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def request_console(self, server: Server | BoundServer) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. @@ -1141,7 +1140,7 @@ def request_console(self, server: Server | BoundServer) -> RequestConsoleRespons method="POST", ) return RequestConsoleResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), wss_url=response["wss_url"], password=response["password"], ) @@ -1173,7 +1172,7 @@ def attach_to_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -1192,7 +1191,7 @@ def detach_from_network( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_alias_ips( self, @@ -1214,7 +1213,7 @@ def change_alias_ips( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_to_placement_group( self, @@ -1233,7 +1232,7 @@ def add_to_placement_group( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_from_placement_group(self, server: Server | BoundServer) -> BoundAction: """Removes a server from a placement group. @@ -1245,4 +1244,4 @@ def remove_from_placement_group(self, server: Server | BoundServer) -> BoundActi url=f"/servers/{server.id}/actions/remove_from_placement_group", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index a68b5153..5fe355c1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -1,13 +1,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import SSHKey -if TYPE_CHECKING: - from .._client import Client - class BoundSSHKey(BoundModelBase, SSHKey): _client: SSHKeysClient @@ -42,7 +39,6 @@ class SSHKeysPageResult(NamedTuple): class SSHKeysClient(ResourceClientBase): - _client: Client def get_by_id(self, id: int) -> BoundSSHKey: """Get a specific SSH Key by its ID diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 15a71c4d..2420d7d2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -21,7 +21,7 @@ class BoundVolume(BoundModelBase, Volume): def __init__(self, client: VolumesClient, data: dict, complete: bool = True): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) # pylint: disable=import-outside-toplevel from ..servers import BoundServer @@ -29,7 +29,7 @@ def __init__(self, client: VolumesClient, data: dict, complete: bool = True): server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) super().__init__(client, data, complete) @@ -136,7 +136,6 @@ class VolumesPageResult(NamedTuple): class VolumesClient(ResourceClientBase): - _client: Client actions: ResourceActionsClient """Volumes scoped actions client @@ -275,9 +274,9 @@ def create( result = CreateVolumeResponse( volume=BoundVolume(self, response["volume"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], ) @@ -320,7 +319,7 @@ def get_actions_list( params=params, ) actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] return ActionsPageResult(actions, Meta.parse_meta(response)) @@ -396,7 +395,7 @@ def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: json={"size": size}, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def attach( self, @@ -420,7 +419,7 @@ def attach( json=data, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def detach(self, volume: Volume | BoundVolume) -> BoundAction: """Detaches a volume from the server it’s attached to. You may attach it to a server again at a later time. @@ -432,7 +431,7 @@ def detach(self, volume: Volume | BoundVolume) -> BoundAction: url=f"/volumes/{volume.id}/actions/detach", method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def change_protection( self, @@ -455,4 +454,4 @@ def change_protection( method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index ed207400..fcd3d12c 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -18,10 +18,6 @@ class TestBoundAction: @pytest.fixture() def bound_running_action(self, client: Client): - # Speed up tests that run `wait_until_finished` - client._poll_interval_func = lambda _: 0.0 - client._poll_max_retries = 3 - return BoundAction( client=client.actions, data=dict(id=14, status=Action.STATUS_RUNNING), @@ -89,7 +85,7 @@ def test_get_by_id( request_mock.return_value = generic_action action = actions_client.get_by_id(1) request_mock.assert_called_with(url="/resource/actions/1", method="GET") - assert action._client == actions_client._client.actions + assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -118,11 +114,11 @@ def test_get_list( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -148,11 +144,11 @@ def test_get_all( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -171,7 +167,7 @@ def test_get_by_id( request_mock.return_value = generic_action action = actions_client.get_by_id(1) request_mock.assert_called_with(url="/actions/1", method="GET") - assert action._client == actions_client._client.actions + assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -199,11 +195,11 @@ def test_get_list( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" @@ -228,10 +224,10 @@ def test_get_all( action1 = actions[0] action2 = actions[1] - assert action1._client == actions_client._client.actions + assert action1._client == actions_client._parent.actions assert action1.id == 1 assert action1.command == "start_server" - assert action2._client == actions_client._client.actions + assert action2._client == actions_client._parent.actions assert action2.id == 2 assert action2.command == "stop_server" diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index d8ebaf0d..1ef152fa 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -358,7 +358,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/certificates/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == certificates_client._client.actions + assert action._client == certificates_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -382,7 +382,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions + assert actions[0]._client == certificates_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -403,6 +403,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions + assert actions[0]._client == certificates_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index d5e760e9..ce6adbe6 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -9,6 +9,12 @@ from hcloud import Client +@pytest.fixture(autouse=True, scope="session") +def patch_package_version(): + with mock.patch("hcloud._client.__version__", "0.0.0"): + yield + + @pytest.fixture() def request_mock() -> mock.MagicMock: return mock.MagicMock() @@ -16,8 +22,13 @@ def request_mock() -> mock.MagicMock: @pytest.fixture() def client(request_mock) -> Client: - c = Client(token="TOKEN") - c.request = request_mock + c = Client( + token="TOKEN", + # Speed up tests that use `_poll_interval_func` + poll_interval=0.0, + poll_max_retries=3, + ) + c._client.request = request_mock return c diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index e3bdf5e4..a2f91375 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -361,7 +361,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" @@ -556,7 +556,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/firewalls/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == firewalls_client._client.actions + assert action._client == firewalls_client._parent.actions assert action.id == 13 assert action.command == "set_firewall_rules" @@ -580,7 +580,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" @@ -601,6 +601,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions + assert actions[0]._client == firewalls_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "set_firewall_rules" diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index e26da382..032d4914 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -366,7 +366,7 @@ def test_get_actions( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" @@ -511,7 +511,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/floating_ips/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == floating_ips_client._client.actions + assert action._client == floating_ips_client._parent.actions assert action.id == 13 assert action.command == "assign_floating_ip" @@ -535,7 +535,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" @@ -556,6 +556,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions + assert actions[0]._client == floating_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_floating_ip" diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 8e6add3a..14ea5334 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -311,7 +311,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -391,7 +391,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/images/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == images_client._client.actions + assert action._client == images_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -415,7 +415,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -436,6 +436,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions + assert actions[0]._client == images_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 158493c2..a2bc4388 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -630,7 +630,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/load_balancers/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == load_balancers_client._client.actions + assert action._client == load_balancers_client._parent.actions assert action.id == 13 assert action.command == "change_protection" @@ -654,7 +654,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions + assert actions[0]._client == load_balancers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" @@ -675,6 +675,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions + assert actions[0]._client == load_balancers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "change_protection" diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index a80f87b1..ae98f485 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -521,7 +521,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" @@ -743,7 +743,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/networks/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == networks_client._client.actions + assert action._client == networks_client._parent.actions assert action.id == 13 assert action.command == "add_subnet" @@ -767,7 +767,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" @@ -788,6 +788,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions + assert actions[0]._client == networks_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "add_subnet" diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index d3809e48..33b74669 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -390,7 +390,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/primary_ips/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == primary_ips_client._client.actions + assert action._client == primary_ips_client._parent.actions assert action.id == 13 assert action.command == "assign_primary_ip" @@ -414,7 +414,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions + assert actions[0]._client == primary_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_primary_ip" @@ -435,6 +435,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions + assert actions[0]._client == primary_ips_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "assign_primary_ip" diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 7f2cd2c1..cb785b0c 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -66,7 +66,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.datacenter, BoundDatacenter) assert ( - bound_server.datacenter._client == bound_server._client._client.datacenters + bound_server.datacenter._client == bound_server._client._parent.datacenters ) assert bound_server.datacenter.id == 1 assert bound_server.datacenter.complete is True @@ -74,30 +74,30 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.server_type, BoundServerType) assert ( bound_server.server_type._client - == bound_server._client._client.server_types + == bound_server._client._parent.server_types ) assert bound_server.server_type.id == 1 assert bound_server.server_type.complete is True assert len(bound_server.volumes) == 2 assert isinstance(bound_server.volumes[0], BoundVolume) - assert bound_server.volumes[0]._client == bound_server._client._client.volumes + assert bound_server.volumes[0]._client == bound_server._client._parent.volumes assert bound_server.volumes[0].id == 1 assert bound_server.volumes[0].complete is False assert isinstance(bound_server.volumes[1], BoundVolume) - assert bound_server.volumes[1]._client == bound_server._client._client.volumes + assert bound_server.volumes[1]._client == bound_server._client._parent.volumes assert bound_server.volumes[1].id == 2 assert bound_server.volumes[1].complete is False assert isinstance(bound_server.image, BoundImage) - assert bound_server.image._client == bound_server._client._client.images + assert bound_server.image._client == bound_server._client._parent.images assert bound_server.image.id == 4711 assert bound_server.image.name == "ubuntu-20.04" assert bound_server.image.complete is True assert isinstance(bound_server.iso, BoundIso) - assert bound_server.iso._client == bound_server._client._client.isos + assert bound_server.iso._client == bound_server._client._parent.isos assert bound_server.iso.id == 4711 assert bound_server.iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" assert bound_server.iso.complete is True @@ -106,7 +106,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.private_net[0], PrivateNet) assert ( bound_server.private_net[0].network._client - == bound_server._client._client.networks + == bound_server._client._parent.networks ) assert bound_server.private_net[0].ip == "10.1.1.5" assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" @@ -116,7 +116,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.placement_group, BoundPlacementGroup) assert ( bound_server.placement_group._client - == bound_server._client._client.placement_groups + == bound_server._client._parent.placement_groups ) assert bound_server.placement_group.id == 897 assert bound_server.placement_group.name == "my Placement Group" @@ -774,7 +774,7 @@ def test_create_with_datacenter( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -811,7 +811,7 @@ def test_create_with_location( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -854,7 +854,7 @@ def test_create_with_volumes( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -899,7 +899,7 @@ def test_create_with_networks( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -944,7 +944,7 @@ def test_create_with_firewalls( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -990,7 +990,7 @@ def test_create_with_placement_group( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -1018,7 +1018,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" @@ -1564,7 +1564,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/servers/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == servers_client._client.actions + assert action._client == servers_client._parent.actions assert action.id == 13 assert action.command == "start_server" @@ -1588,7 +1588,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" @@ -1609,6 +1609,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions + assert actions[0]._client == servers_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "start_server" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 4731336c..41094a88 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,7 +1,9 @@ from __future__ import annotations -import json -from unittest.mock import MagicMock +from http import HTTPStatus +from json import dumps +from typing import Any +from unittest import mock import pytest import requests @@ -12,201 +14,86 @@ constant_backoff_function, exponential_backoff_function, ) +from hcloud._client import ClientBase, _build_user_agent -class TestHetznerClient: +def test_exponential_backoff_function(): + backoff = exponential_backoff_function( + base=1.0, + multiplier=2, + cap=60.0, + ) + max_retries = 5 + + results = [backoff(i) for i in range(max_retries)] + assert sum(results) == 31.0 + assert results == [1.0, 2.0, 4.0, 8.0, 16.0] + + +def test_constant_backoff_function(): + backoff = constant_backoff_function(interval=1.0) + max_retries = 5 + + for i in range(max_retries): + assert backoff(i) == 1.0 + + +def test_build_user_agent(): + assert _build_user_agent(None, None) == "hcloud-python/0.0.0" + assert _build_user_agent("my-app", None) == "my-app hcloud-python/0.0.0" + assert _build_user_agent("my-app", "1.0.0") == "my-app/1.0.0 hcloud-python/0.0.0" + assert _build_user_agent(None, "1.0.0") == "hcloud-python/0.0.0" + + +class TestClient: @pytest.fixture() def client(self): - Client._version = "0.0.0" - client = Client(token="project_token") + return Client(token="TOKEN") - client._requests_session = MagicMock() - return client + def test_request(self, client: Client): + client._client.request = mock.MagicMock() + client.request(method="GET", url="/path") + client._client.request.assert_called_once_with("GET", "/path") - @pytest.fixture() - def response(self): - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - return response - @pytest.fixture() - def fail_response(self, response): - response.status_code = 422 - error = { - "code": "invalid_input", - "message": "invalid input in field 'broken_field': is too long", - "details": { - "fields": [{"name": "broken_field", "messages": ["is too long"]}] - }, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response +def make_response( + status: HTTPStatus, + *, + json: Any | None = None, + text: str | None = None, +) -> requests.Response: + response = requests.Response() + response.status_code = status.value + response.reason = status.phrase + if json is not None: + response.headers["Content-type"] = "application/json" + response._content = dumps(json).encode("utf-8") + elif text is not None: + response.headers["Content-type"] = "text/plain" + response._content = text.encode("utf-8") + return response + + +class TestBaseClient: @pytest.fixture() - def rate_limit_response(self, response): - response.status_code = 422 - error = { - "code": "rate_limit_exceeded", - "message": "limit of 10 requests per hour reached", - "details": {}, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response - - def test__get_user_agent(self, client): - user_agent = client._get_user_agent() - assert user_agent == "hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name(self, client): - client = Client(token="project_token", application_name="my-app") - user_agent = client._get_user_agent() - assert user_agent == "my-app hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name_and_version(self, client): - client = Client( - token="project_token", - application_name="my-app", - application_version="1.0.0", + def client(self): + client = ClientBase( + token="TOKEN", + endpoint="https://api.hetzner.cloud/v1", ) - user_agent = client._get_user_agent() - assert user_agent == "my-app/1.0.0 hcloud-python/0.0.0" + client._session = mock.MagicMock() + return client - def test__get_headers(self, client): - headers = client._get_headers() - assert headers == { + def test_init(self, client: ClientBase): + assert client._user_agent == "hcloud-python/0.0.0" + assert client._headers == { "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", } - - def test_request_ok(self, client, response): - client._requests_session.request.return_value = response - response = client.request( - "POST", "/servers", params={"argument": "value"}, timeout=2 - ) - client._requests_session.request.assert_called_once_with( - method="POST", - url="https://api.hetzner.cloud/v1/servers", - headers={ - "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", - }, - params={"argument": "value"}, - timeout=2, - ) - assert response == {"result": "data"} - - def test_request_fails(self, client, fail_response): - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == "invalid_input" - assert error.message == "invalid input in field 'broken_field': is too long" - assert error.details["fields"][0]["name"] == "broken_field" - - def test_request_fails_correlation_id(self, client, response): - response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" - response.status_code = 422 - response._content = json.dumps( - { - "error": { - "code": "service_error", - "message": "Something crashed", - } - } - ).encode("utf-8") - - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == "service_error" - assert error.message == "Something crashed" - assert error.details is None - assert error.correlation_id == "67ed842dc8bc8673" - assert str(error) == "Something crashed (service_error, 67ed842dc8bc8673)" - - def test_request_500(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "Internal Server Error" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "Internal Server Error" - - def test_request_broken_json_200(self, client, response): - content = b"{'key': 'value'" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 200 - assert error.message == "OK" - assert error.details["content"] == content - - def test_request_empty_content_200(self, client, response): - content = "" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - response = client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - assert response == {} - - def test_request_500_empty_content(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "" - assert str(error) == "Internal Server Error (500)" - - def test_request_limit(self, client, rate_limit_response): - client._retry_interval = constant_backoff_function(0.0) - client._requests_session.request.return_value = rate_limit_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert client._requests_session.request.call_count == 6 - assert error.code == "rate_limit_exceeded" - assert error.message == "limit of 10 requests per hour reached" - - def test_request_limit_then_success(self, client, rate_limit_response): - client._retry_interval = constant_backoff_function(0.0) - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - client._requests_session.request.side_effect = [rate_limit_response, response] - - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - assert client._requests_session.request.call_count == 2 + assert client._poll_interval_func(1) == 1.0 + assert client._retry_interval_func(1) == pytest.approx(1.5, rel=0.5) # Jitter @pytest.mark.parametrize( ("exception", "expected"), @@ -241,26 +128,200 @@ def test_request_limit_then_success(self, client, rate_limit_response): ), ], ) - def test_retry_policy(self, client, exception, expected): + def test_retry_policy( + self, + client: ClientBase, + exception: APIException, + expected: bool, + ): assert client._retry_policy(exception) == expected + def test_request_200(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ) -def test_constant_backoff_function(): - backoff = constant_backoff_function(interval=1.0) - max_retries = 5 + result = client.request( + method="POST", + url="/path", + params={"argument": "value"}, + timeout=2, + ) - for i in range(max_retries): - assert backoff(i) == 1.0 + client._session.request.assert_called_once_with( + method="POST", + url="https://api.hetzner.cloud/v1/path", + headers={ + "User-Agent": "hcloud-python/0.0.0", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", + }, + params={"argument": "value"}, + timeout=2, + ) + assert result == {"result": "data"} + def test_request_200_empty_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="", + ) -def test_exponential_backoff_function(): - backoff = exponential_backoff_function( - base=1.0, - multiplier=2, - cap=60.0, - ) - max_retries = 5 + result = client.request(method="POST", url="/path") + assert result == {} - results = [backoff(i) for i in range(max_retries)] - assert sum(results) == 31.0 - assert results == [1.0, 2.0, 4.0, 8.0, 16.0] + def test_request_fail_200_invalid_json(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="{'key': 'value'", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 200 + assert exc.value.message == "OK" + assert exc.value.details["content"] == b"{'key': 'value'" + + def test_request_fail_422(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "invalid_input", + "message": "invalid input in field 'broken_field': is too long", + "details": { + "fields": [ + {"name": "broken_field", "messages": ["is too long"]} + ] + }, + } + }, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "invalid_input" + assert exc.value.message == "invalid input in field 'broken_field': is too long" + assert exc.value.details["fields"][0]["name"] == "broken_field" + + def test_request_fail_422_correlation_id(self, client: ClientBase): + response = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "service_error", + "message": "Something crashed", + } + }, + ) + response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" + client._session.request.return_value = response + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "service_error" + assert exc.value.message == "Something crashed" + assert exc.value.details is None + assert exc.value.correlation_id == "67ed842dc8bc8673" + assert str(exc.value) == "Something crashed (service_error, 67ed842dc8bc8673)" + + def test_request_fail_500(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + text="Internal Server Error", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] == b"Internal Server Error" + + def test_request_fail_500_no_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] is None + assert str(exc.value) == "Internal Server Error (500)" + + def test_request_fail_419(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.return_value = make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ) + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert client._session.request.call_count == 6 + assert exc.value.code == "rate_limit_exceeded" + assert exc.value.message == "limit of 3600 requests per hour reached" + + def test_request_fail_419_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} + + def test_request_fail_timeout(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + client._session.request.side_effect = requests.exceptions.Timeout("timeout") + + with pytest.raises(requests.exceptions.Timeout) as exc: + client.request(method="GET", url="/path") + + assert str(exc.value) == "timeout" + assert client._session.request.call_count == 6 + + def test_request_fail_timeout_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + requests.exceptions.Timeout("timeout"), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 3f5865ab..1f5f7534 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -378,7 +378,7 @@ def test_get_actions_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" @@ -510,7 +510,7 @@ def test_actions_get_by_id( request_mock.assert_called_with(url="/volumes/actions/13", method="GET") assert isinstance(action, BoundAction) - assert action._client == volumes_client._client.actions + assert action._client == volumes_client._parent.actions assert action.id == 13 assert action.command == "attach_volume" @@ -534,7 +534,7 @@ def test_actions_get_list( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" @@ -555,6 +555,6 @@ def test_actions_get_all( assert len(actions) == 1 assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions + assert actions[0]._client == volumes_client._parent.actions assert actions[0].id == 13 assert actions[0].command == "attach_volume" From a73e7d4085449007daec69afba0b5b447fae3e52 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 15 Aug 2025 17:11:03 +0200 Subject: [PATCH 086/213] style: format test case (#543) - Make it more breathable, - Break calls on multiple lines --- tests/unit/actions/test_client.py | 52 ++- tests/unit/certificates/test_client.py | 91 ++++- tests/unit/datacenters/test_client.py | 31 +- tests/unit/firewalls/test_client.py | 103 ++++-- tests/unit/floating_ips/test_client.py | 115 +++++-- tests/unit/images/test_client.py | 94 ++++- tests/unit/isos/test_client.py | 29 +- tests/unit/load_balancer_types/test_client.py | 23 +- tests/unit/load_balancers/test_client.py | 146 ++++++-- tests/unit/locations/test_client.py | 29 +- tests/unit/networks/test_client.py | 147 ++++++-- tests/unit/placement_groups/test_client.py | 38 +- tests/unit/primary_ips/test_client.py | 95 +++-- tests/unit/server_types/test_client.py | 23 +- tests/unit/servers/test_client.py | 324 ++++++++++++++---- tests/unit/ssh_keys/test_client.py | 69 +++- tests/unit/volumes/test_client.py | 136 ++++++-- 17 files changed, 1242 insertions(+), 303 deletions(-) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index fcd3d12c..761452d0 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -30,9 +30,17 @@ def test_wait_until_finished( running_action, successfully_action, ): - request_mock.side_effect = [running_action, successfully_action] + request_mock.side_effect = [ + running_action, + successfully_action, + ] + bound_running_action.wait_until_finished() - request_mock.assert_called_with(url="/actions/2", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/actions/2", + ) assert bound_running_action.status == "success" assert request_mock.call_count == 2 @@ -83,8 +91,13 @@ def test_get_by_id( generic_action, ): request_mock.return_value = generic_action + action = actions_client.get_by_id(1) - request_mock.assert_called_with(url="/resource/actions/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/resource/actions/1", + ) assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -101,9 +114,13 @@ def test_get_list( params, ): request_mock.return_value = generic_action_list + result = actions_client.get_list(**params) + request_mock.assert_called_with( - url="/resource/actions", method="GET", params=params + method="GET", + url="/resource/actions", + params=params, ) assert result.meta is not None @@ -131,12 +148,15 @@ def test_get_all( params, ): request_mock.return_value = generic_action_list + actions = actions_client.get_all(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/resource/actions", method="GET", params=params + method="GET", + url="/resource/actions", + params=params, ) assert len(actions) == 2 @@ -165,8 +185,13 @@ def test_get_by_id( generic_action, ): request_mock.return_value = generic_action + action = actions_client.get_by_id(1) - request_mock.assert_called_with(url="/actions/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/actions/1", + ) assert action._client == actions_client._parent.actions assert action.id == 1 assert action.command == "stop_server" @@ -183,9 +208,15 @@ def test_get_list( params, ): request_mock.return_value = generic_action_list + with pytest.deprecated_call(): result = actions_client.get_list(**params) - request_mock.assert_called_with(url="/actions", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/actions", + params=params, + ) assert result.meta is not None @@ -212,12 +243,17 @@ def test_get_all( params, ): request_mock.return_value = generic_action_list + with pytest.deprecated_call(): actions = actions_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/actions", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/actions", + params=params, + ) assert len(actions) == 2 diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 1ef152fa..0c7c8c7f 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -29,9 +29,13 @@ def test_get_actions_list( params, ): request_mock.return_value = response_get_actions + result = bound_certificate.get_actions_list(**params) + request_mock.assert_called_with( - url="/certificates/14/actions", method="GET", params=params + method="GET", + url="/certificates/14/actions", + params=params, ) actions = result.actions @@ -51,12 +55,15 @@ def test_get_actions( response_get_actions, ): request_mock.return_value = response_get_actions + actions = bound_certificate.get_actions() params = {"page": 1, "per_page": 50} request_mock.assert_called_with( - url="/certificates/14/actions", method="GET", params=params + method="GET", + url="/certificates/14/actions", + params=params, ) assert len(actions) == 1 @@ -95,9 +102,13 @@ def test_update( response_update_certificate, ): request_mock.return_value = response_update_certificate + certificate = bound_certificate.update(name="New name") + request_mock.assert_called_with( - url="/certificates/14", method="PUT", json={"name": "New name"} + method="PUT", + url="/certificates/14", + json={"name": "New name"}, ) assert certificate.id == 2323 @@ -110,8 +121,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_certificate.delete() - request_mock.assert_called_with(url="/certificates/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/certificates/14", + ) assert delete_success is True @@ -122,9 +138,12 @@ def test_retry_issuance( response_retry_issuance_action, ): request_mock.return_value = response_retry_issuance_action + action = bound_certificate.retry_issuance() + request_mock.assert_called_with( - url="/certificates/14/actions/retry", method="POST" + method="POST", + url="/certificates/14/actions/retry", ) assert action.id == 14 @@ -143,8 +162,13 @@ def test_get_by_id( certificate_response, ): request_mock.return_value = certificate_response + certificate = certificates_client.get_by_id(1) - request_mock.assert_called_with(url="/certificates/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/certificates/1", + ) assert certificate._client is certificates_client assert certificate.id == 2323 assert certificate.name == "My Certificate" @@ -170,9 +194,13 @@ def test_get_list( params, ): request_mock.return_value = two_certificates_response + result = certificates_client.get_list(**params) + request_mock.assert_called_with( - url="/certificates", method="GET", params=params + method="GET", + url="/certificates", + params=params, ) certificates = result.certificates @@ -200,11 +228,15 @@ def test_get_all( params, ): request_mock.return_value = two_certificates_response + certificates = certificates_client.get_all(**params) params.update({"page": 1, "per_page": 50}) + request_mock.assert_called_with( - url="/certificates", method="GET", params=params + method="GET", + url="/certificates", + params=params, ) assert len(certificates) == 2 @@ -227,11 +259,15 @@ def test_get_by_name( one_certificates_response, ): request_mock.return_value = one_certificates_response + certificates = certificates_client.get_by_name("My Certificate") params = {"name": "My Certificate"} + request_mock.assert_called_with( - url="/certificates", method="GET", params=params + method="GET", + url="/certificates", + params=params, ) assert certificates._client is certificates_client @@ -245,14 +281,16 @@ def test_create( certificate_response, ): request_mock.return_value = certificate_response + certificate = certificates_client.create( name="My Certificate", certificate="-----BEGIN CERTIFICATE-----\n...", private_key="-----BEGIN PRIVATE KEY-----\n...", ) + request_mock.assert_called_with( - url="/certificates", method="POST", + url="/certificates", json={ "name": "My Certificate", "certificate": "-----BEGIN CERTIFICATE-----\n...", @@ -271,12 +309,14 @@ def test_create_managed( create_managed_certificate_response, ): request_mock.return_value = create_managed_certificate_response + create_managed_certificate_rsp = certificates_client.create_managed( name="My Certificate", domain_names=["example.com", "*.example.org"] ) + request_mock.assert_called_with( - url="/certificates", method="POST", + url="/certificates", json={ "name": "My Certificate", "domain_names": ["example.com", "*.example.org"], @@ -301,9 +341,13 @@ def test_update( response_update_certificate, ): request_mock.return_value = response_update_certificate + certificate = certificates_client.update(certificate, name="New name") + request_mock.assert_called_with( - url="/certificates/1", method="PUT", json={"name": "New name"} + method="PUT", + url="/certificates/1", + json={"name": "New name"}, ) assert certificate.id == 2323 @@ -321,8 +365,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = certificates_client.delete(certificate) - request_mock.assert_called_with(url="/certificates/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/certificates/1", + ) assert delete_success is True @@ -338,9 +387,12 @@ def test_retry_issuance( response_retry_issuance_action, ): request_mock.return_value = response_retry_issuance_action + action = certificates_client.retry_issuance(certificate) + request_mock.assert_called_with( - url="/certificates/1/actions/retry", method="POST" + method="POST", + url="/certificates/1/actions/retry", ) assert action.id == 14 @@ -355,7 +407,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = certificates_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/certificates/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/certificates/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == certificates_client._parent.actions @@ -369,11 +424,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = certificates_client.actions.get_list() request_mock.assert_called_with( - url="/certificates/actions", method="GET", + url="/certificates/actions", params={}, ) @@ -393,11 +449,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = certificates_client.actions.get_all() request_mock.assert_called_with( - url="/certificates/actions", method="GET", + url="/certificates/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index c51331bd..7f5ca96b 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -69,8 +69,13 @@ def test_get_by_id( datacenter_response, ): request_mock.return_value = datacenter_response + datacenter = datacenters_client.get_by_id(1) - request_mock.assert_called_with(url="/datacenters/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/datacenters/1", + ) assert datacenter._client is datacenters_client assert datacenter.id == 1 assert datacenter.name == "fsn1-dc8" @@ -86,8 +91,14 @@ def test_get_list( params, ): request_mock.return_value = two_datacenters_response + result = datacenters_client.get_list(**params) - request_mock.assert_called_with(url="/datacenters", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, + ) datacenters = result.datacenters assert result.meta is not None @@ -116,10 +127,16 @@ def test_get_all( params, ): request_mock.return_value = two_datacenters_response + datacenters = datacenters_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/datacenters", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, + ) assert len(datacenters) == 2 @@ -143,10 +160,16 @@ def test_get_by_name( one_datacenters_response, ): request_mock.return_value = one_datacenters_response + datacenter = datacenters_client.get_by_name("fsn1-dc8") params = {"name": "fsn1-dc8"} - request_mock.assert_called_with(url="/datacenters", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, + ) assert datacenter._client is datacenters_client assert datacenter.id == 1 diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index a2f91375..9c65a26b 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -84,9 +84,13 @@ def test_get_actions_list( params, ): request_mock.return_value = response_get_actions + result = bound_firewall.get_actions_list(**params) + request_mock.assert_called_with( - url="/firewalls/1/actions", method="GET", params=params + method="GET", + url="/firewalls/1/actions", + params=params, ) actions = result.actions @@ -108,12 +112,15 @@ def test_get_actions( params, ): request_mock.return_value = response_get_actions + actions = bound_firewall.get_actions(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/firewalls/1/actions", method="GET", params=params + method="GET", + url="/firewalls/1/actions", + params=params, ) assert len(actions) == 1 @@ -129,12 +136,14 @@ def test_update( response_update_firewall, ): request_mock.return_value = response_update_firewall + firewall = bound_firewall.update( name="New Corporate Intranet Protection", labels={} ) + request_mock.assert_called_with( - url="/firewalls/1", method="PUT", + url="/firewalls/1", json={"name": "New Corporate Intranet Protection", "labels": {}}, ) @@ -147,7 +156,11 @@ def test_delete( bound_firewall, ): delete_success = bound_firewall.delete() - request_mock.assert_called_with(url="/firewalls/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/firewalls/1", + ) assert delete_success is True @@ -158,6 +171,7 @@ def test_set_rules( response_set_rules, ): request_mock.return_value = response_set_rules + actions = bound_firewall.set_rules( [ FirewallRule( @@ -168,9 +182,10 @@ def test_set_rules( ) ] ) + request_mock.assert_called_with( - url="/firewalls/1/actions/set_rules", method="POST", + url="/firewalls/1/actions/set_rules", json={ "rules": [ { @@ -193,12 +208,14 @@ def test_apply_to_resources( response_set_rules, ): request_mock.return_value = response_set_rules + actions = bound_firewall.apply_to_resources( [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] ) + request_mock.assert_called_with( - url="/firewalls/1/actions/apply_to_resources", method="POST", + url="/firewalls/1/actions/apply_to_resources", json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, ) @@ -212,12 +229,14 @@ def test_remove_from_resources( response_set_rules, ): request_mock.return_value = response_set_rules + actions = bound_firewall.remove_from_resources( [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] ) + request_mock.assert_called_with( - url="/firewalls/1/actions/remove_from_resources", method="POST", + url="/firewalls/1/actions/remove_from_resources", json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, ) @@ -237,8 +256,13 @@ def test_get_by_id( firewall_response, ): request_mock.return_value = firewall_response + firewall = firewalls_client.get_by_id(1) - request_mock.assert_called_with(url="/firewalls/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/firewalls/1", + ) assert firewall._client is firewalls_client assert firewall.id == 38 assert firewall.name == "Corporate Intranet Protection" @@ -265,8 +289,14 @@ def test_get_list( params, ): request_mock.return_value = two_firewalls_response + result = firewalls_client.get_list(**params) - request_mock.assert_called_with(url="/firewalls", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, + ) firewalls = result.firewalls assert result.meta is not None @@ -303,11 +333,16 @@ def test_get_all( params, ): request_mock.return_value = two_firewalls_response + firewalls = firewalls_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/firewalls", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, + ) assert len(firewalls) == 2 @@ -329,11 +364,16 @@ def test_get_by_name( one_firewalls_response, ): request_mock.return_value = one_firewalls_response + firewall = firewalls_client.get_by_name("Corporate Intranet Protection") params = {"name": "Corporate Intranet Protection"} - request_mock.assert_called_with(url="/firewalls", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, + ) assert firewall._client is firewalls_client assert firewall.id == 38 @@ -350,9 +390,13 @@ def test_get_actions_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = firewalls_client.get_actions_list(firewall) + request_mock.assert_called_with( - url="/firewalls/1/actions", method="GET", params={} + method="GET", + url="/firewalls/1/actions", + params={}, ) actions = result.actions @@ -372,6 +416,7 @@ def test_create( response_create_firewall, ): request_mock.return_value = response_create_firewall + response = firewalls_client.create( "Corporate Intranet Protection", rules=[ @@ -391,9 +436,10 @@ def test_create( ), ], ) + request_mock.assert_called_with( - url="/firewalls", method="POST", + url="/firewalls", json={ "name": "Corporate Intranet Protection", "rules": [ @@ -430,12 +476,14 @@ def test_update( response_update_firewall, ): request_mock.return_value = response_update_firewall + firewall = firewalls_client.update( firewall, name="New Corporate Intranet Protection", labels={} ) + request_mock.assert_called_with( - url="/firewalls/38", method="PUT", + url="/firewalls/38", json={"name": "New Corporate Intranet Protection", "labels": {}}, ) @@ -453,6 +501,7 @@ def test_set_rules( response_set_rules, ): request_mock.return_value = response_set_rules + actions = firewalls_client.set_rules( firewall, [ @@ -463,9 +512,10 @@ def test_set_rules( ) ], ) + request_mock.assert_called_with( - url="/firewalls/1/actions/set_rules", method="POST", + url="/firewalls/1/actions/set_rules", json={ "rules": [ { @@ -490,7 +540,11 @@ def test_delete( firewall, ): delete_success = firewalls_client.delete(firewall) - request_mock.assert_called_with(url="/firewalls/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/firewalls/1", + ) assert delete_success is True @@ -510,9 +564,10 @@ def test_apply_to_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) + request_mock.assert_called_with( - url="/firewalls/1/actions/apply_to_resources", method="POST", + url="/firewalls/1/actions/apply_to_resources", json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, ) @@ -535,9 +590,10 @@ def test_remove_from_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) + request_mock.assert_called_with( - url="/firewalls/1/actions/remove_from_resources", method="POST", + url="/firewalls/1/actions/remove_from_resources", json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, ) @@ -553,7 +609,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = firewalls_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/firewalls/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/firewalls/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == firewalls_client._parent.actions @@ -567,11 +626,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = firewalls_client.actions.get_list() request_mock.assert_called_with( - url="/firewalls/actions", method="GET", + url="/firewalls/actions", params={}, ) @@ -591,11 +651,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = firewalls_client.actions.get_all() request_mock.assert_called_with( - url="/firewalls/actions", method="GET", + url="/firewalls/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index 032d4914..1cc5bd1f 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -50,10 +50,12 @@ def test_get_actions( response_get_actions, ): request_mock.return_value = response_get_actions + actions = bound_floating_ip.get_actions(sort="id") + request_mock.assert_called_with( - url="/floating_ips/14/actions", method="GET", + url="/floating_ips/14/actions", params={"sort": "id", "page": 1, "per_page": 50}, ) @@ -70,12 +72,14 @@ def test_update( response_update_floating_ip, ): request_mock.return_value = response_update_floating_ip + floating_ip = bound_floating_ip.update( description="New description", name="New name" ) + request_mock.assert_called_with( - url="/floating_ips/14", method="PUT", + url="/floating_ips/14", json={"description": "New description", "name": "New name"}, ) @@ -90,8 +94,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_floating_ip.delete() - request_mock.assert_called_with(url="/floating_ips/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/floating_ips/14", + ) assert delete_success is True @@ -102,10 +111,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_floating_ip.change_protection(True) + request_mock.assert_called_with( - url="/floating_ips/14/actions/change_protection", method="POST", + url="/floating_ips/14/actions/change_protection", json={"delete": True}, ) @@ -123,9 +134,13 @@ def test_assign( generic_action, ): request_mock.return_value = generic_action + action = bound_floating_ip.assign(server) + request_mock.assert_called_with( - url="/floating_ips/14/actions/assign", method="POST", json={"server": 1} + method="POST", + url="/floating_ips/14/actions/assign", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 @@ -137,9 +152,12 @@ def test_unassign( generic_action, ): request_mock.return_value = generic_action + action = bound_floating_ip.unassign() + request_mock.assert_called_with( - url="/floating_ips/14/actions/unassign", method="POST" + method="POST", + url="/floating_ips/14/actions/unassign", ) assert action.id == 1 assert action.progress == 0 @@ -151,10 +169,12 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") + request_mock.assert_called_with( - url="/floating_ips/14/actions/change_dns_ptr", method="POST", + url="/floating_ips/14/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 @@ -173,8 +193,13 @@ def test_get_by_id( floating_ip_response, ): request_mock.return_value = floating_ip_response + bound_floating_ip = floating_ips_client.get_by_id(1) - request_mock.assert_called_with(url="/floating_ips/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/floating_ips/1", + ) assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 assert bound_floating_ip.description == "Web Frontend" @@ -186,9 +211,13 @@ def test_get_by_name( one_floating_ips_response, ): request_mock.return_value = one_floating_ips_response + bound_floating_ip = floating_ips_client.get_by_name("Web Frontend") + request_mock.assert_called_with( - url="/floating_ips", method="GET", params={"name": "Web Frontend"} + method="GET", + url="/floating_ips", + params={"name": "Web Frontend"}, ) assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 @@ -207,9 +236,13 @@ def test_get_list( params, ): request_mock.return_value = two_floating_ips_response + result = floating_ips_client.get_list(**params) + request_mock.assert_called_with( - url="/floating_ips", method="GET", params=params + method="GET", + url="/floating_ips", + params=params, ) bound_floating_ips = result.floating_ips @@ -237,12 +270,15 @@ def test_get_all( params, ): request_mock.return_value = two_floating_ips_response + bound_floating_ips = floating_ips_client.get_all(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/floating_ips", method="GET", params=params + method="GET", + url="/floating_ips", + params=params, ) assert len(bound_floating_ips) == 2 @@ -265,12 +301,14 @@ def test_create_with_location( floating_ip_response, ): request_mock.return_value = floating_ip_response + response = floating_ips_client.create( "ipv6", "Web Frontend", home_location=Location(name="location") ) + request_mock.assert_called_with( - url="/floating_ips", method="POST", + url="/floating_ips", json={ "description": "Web Frontend", "type": "ipv6", @@ -297,12 +335,14 @@ def test_create_with_server( floating_ip_create_response, ): request_mock.return_value = floating_ip_create_response + response = floating_ips_client.create( type="ipv6", description="Web Frontend", server=server ) + request_mock.assert_called_with( - url="/floating_ips", method="POST", + url="/floating_ips", json={"description": "Web Frontend", "type": "ipv6", "server": 1}, ) bound_floating_ip = response.floating_ip @@ -324,12 +364,14 @@ def test_create_with_name( floating_ip_create_response, ): request_mock.return_value = floating_ip_create_response + response = floating_ips_client.create( type="ipv6", description="Web Frontend", name="Web Frontend" ) + request_mock.assert_called_with( - url="/floating_ips", method="POST", + url="/floating_ips", json={ "description": "Web Frontend", "type": "ipv6", @@ -356,10 +398,12 @@ def test_get_actions( response_get_actions, ): request_mock.return_value = response_get_actions + actions = floating_ips_client.get_actions(floating_ip) + request_mock.assert_called_with( - url="/floating_ips/1/actions", method="GET", + url="/floating_ips/1/actions", params={"page": 1, "per_page": 50}, ) @@ -381,12 +425,14 @@ def test_update( response_update_floating_ip, ): request_mock.return_value = response_update_floating_ip + floating_ip = floating_ips_client.update( floating_ip, description="New description", name="New name" ) + request_mock.assert_called_with( - url="/floating_ips/1", method="PUT", + url="/floating_ips/1", json={"description": "New description", "name": "New name"}, ) @@ -405,10 +451,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = floating_ips_client.change_protection(floating_ip, True) + request_mock.assert_called_with( - url="/floating_ips/1/actions/change_protection", method="POST", + url="/floating_ips/1/actions/change_protection", json={"delete": True}, ) @@ -426,8 +474,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = floating_ips_client.delete(floating_ip) - request_mock.assert_called_with(url="/floating_ips/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/floating_ips/1", + ) assert delete_success is True @@ -450,9 +503,13 @@ def test_assign( generic_action, ): request_mock.return_value = generic_action + action = floating_ips_client.assign(floating_ip, server) + request_mock.assert_called_with( - url="/floating_ips/12/actions/assign", method="POST", json={"server": 1} + method="POST", + url="/floating_ips/12/actions/assign", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 @@ -469,9 +526,12 @@ def test_unassign( generic_action, ): request_mock.return_value = generic_action + action = floating_ips_client.unassign(floating_ip) + request_mock.assert_called_with( - url="/floating_ips/12/actions/unassign", method="POST" + method="POST", + url="/floating_ips/12/actions/unassign", ) assert action.id == 1 assert action.progress == 0 @@ -488,12 +548,14 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = floating_ips_client.change_dns_ptr( floating_ip, "1.2.3.4", "server02.example.com" ) + request_mock.assert_called_with( - url="/floating_ips/12/actions/change_dns_ptr", method="POST", + url="/floating_ips/12/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 @@ -508,7 +570,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = floating_ips_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/floating_ips/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/floating_ips/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == floating_ips_client._parent.actions @@ -522,11 +587,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = floating_ips_client.actions.get_list() request_mock.assert_called_with( - url="/floating_ips/actions", method="GET", + url="/floating_ips/actions", params={}, ) @@ -546,11 +612,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = floating_ips_client.actions.get_all() request_mock.assert_called_with( - url="/floating_ips/actions", method="GET", + url="/floating_ips/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 14ea5334..384dce48 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -59,9 +59,13 @@ def test_get_actions_list( params, ): request_mock.return_value = response_get_actions + result = bound_image.get_actions_list(**params) + request_mock.assert_called_with( - url="/images/14/actions", method="GET", params=params + method="GET", + url="/images/14/actions", + params=params, ) actions = result.actions @@ -83,12 +87,15 @@ def test_get_actions( params, ): request_mock.return_value = response_get_actions + actions = bound_image.get_actions(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/images/14/actions", method="GET", params=params + method="GET", + url="/images/14/actions", + params=params, ) assert len(actions) == 1 @@ -104,12 +111,14 @@ def test_update( response_update_image, ): request_mock.return_value = response_update_image + image = bound_image.update( description="My new Image description", type="snapshot", labels={} ) + request_mock.assert_called_with( - url="/images/14", method="PUT", + url="/images/14", json={ "description": "My new Image description", "type": "snapshot", @@ -127,8 +136,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_image.delete() - request_mock.assert_called_with(url="/images/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/images/14", + ) assert delete_success is True @@ -139,10 +153,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_image.change_protection(True) + request_mock.assert_called_with( - url="/images/14/actions/change_protection", method="POST", + url="/images/14/actions/change_protection", json={"delete": True}, ) @@ -162,8 +178,13 @@ def test_get_by_id( image_response, ): request_mock.return_value = image_response + image = images_client.get_by_id(1) - request_mock.assert_called_with(url="/images/1", method="GET") + + 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" @@ -193,8 +214,14 @@ def test_get_list( params, ): request_mock.return_value = two_images_response + result = images_client.get_list(**params) - request_mock.assert_called_with(url="/images", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, + ) images = result.images assert result.meta is not None @@ -234,11 +261,16 @@ def test_get_all( params, ): request_mock.return_value = two_images_response + images = images_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/images", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, + ) assert len(images) == 2 @@ -260,12 +292,17 @@ def test_get_by_name( one_images_response, ): request_mock.return_value = one_images_response + with pytest.deprecated_call(): image = images_client.get_by_name("ubuntu-20.04") params = {"name": "ubuntu-20.04"} - request_mock.assert_called_with(url="/images", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, + ) assert image._client is images_client assert image.id == 4711 @@ -278,11 +315,16 @@ def test_get_by_name_and_architecture( one_images_response, ): request_mock.return_value = one_images_response + image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") params = {"name": "ubuntu-20.04", "architecture": ["x86"]} - request_mock.assert_called_with(url="/images", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, + ) assert image._client is images_client assert image.id == 4711 @@ -300,9 +342,13 @@ def test_get_actions_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = images_client.get_actions_list(image) + request_mock.assert_called_with( - url="/images/1/actions", method="GET", params={} + method="GET", + url="/images/1/actions", + params={}, ) actions = result.actions @@ -326,12 +372,14 @@ def test_update( response_update_image, ): request_mock.return_value = response_update_image + image = images_client.update( image, description="My new Image description", type="snapshot", labels={} ) + request_mock.assert_called_with( - url="/images/1", method="PUT", + url="/images/1", json={ "description": "My new Image description", "type": "snapshot", @@ -353,10 +401,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = images_client.change_protection(image, True) + request_mock.assert_called_with( - url="/images/1/actions/change_protection", method="POST", + url="/images/1/actions/change_protection", json={"delete": True}, ) @@ -374,8 +424,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = images_client.delete(image) - request_mock.assert_called_with(url="/images/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/images/1", + ) assert delete_success is True @@ -388,7 +443,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = images_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/images/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/images/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == images_client._parent.actions @@ -402,11 +460,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = images_client.actions.get_list() request_mock.assert_called_with( - url="/images/actions", method="GET", + url="/images/actions", params={}, ) @@ -426,11 +485,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = images_client.actions.get_all() request_mock.assert_called_with( - url="/images/actions", method="GET", + url="/images/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index 2f57f170..3735f26d 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -47,8 +47,13 @@ def test_get_by_id( iso_response, ): request_mock.return_value = iso_response + iso = isos_client.get_by_id(1) - request_mock.assert_called_with(url="/isos/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/isos/1", + ) assert iso._client is isos_client assert iso.id == 4711 assert iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" @@ -69,8 +74,14 @@ def test_get_list( params, ): request_mock.return_value = two_isos_response + result = isos_client.get_list(**params) - request_mock.assert_called_with(url="/isos", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, + ) isos = result.isos assert result.meta is not None @@ -99,11 +110,16 @@ def test_get_all( params, ): request_mock.return_value = two_isos_response + isos = isos_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/isos", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, + ) assert len(isos) == 2 @@ -125,11 +141,16 @@ def test_get_by_name( one_isos_response, ): request_mock.return_value = one_isos_response + iso = isos_client.get_by_name("FreeBSD-11.0-RELEASE-amd64-dvd1") params = {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1"} - request_mock.assert_called_with(url="/isos", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, + ) assert iso._client is isos_client assert iso.id == 4711 diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index 96e65e9a..c9f356dc 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -20,8 +20,13 @@ def test_get_by_id( load_balancer_type_response, ): request_mock.return_value = load_balancer_type_response + load_balancer_type = load_balancer_types_client.get_by_id(1) - request_mock.assert_called_with(url="/load_balancer_types/1", method="GET") + + 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" @@ -37,9 +42,13 @@ def test_get_list( params, ): request_mock.return_value = two_load_balancer_types_response + result = load_balancer_types_client.get_list(**params) + request_mock.assert_called_with( - url="/load_balancer_types", method="GET", params=params + method="GET", + url="/load_balancer_types", + params=params, ) load_balancer_types = result.load_balancer_types @@ -67,12 +76,15 @@ def test_get_all( params, ): request_mock.return_value = two_load_balancer_types_response + load_balancer_types = load_balancer_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/load_balancer_types", method="GET", params=params + method="GET", + url="/load_balancer_types", + params=params, ) assert len(load_balancer_types) == 2 @@ -95,12 +107,15 @@ def test_get_by_name( one_load_balancer_types_response, ): request_mock.return_value = one_load_balancer_types_response + load_balancer_type = load_balancer_types_client.get_by_name("LB21") params = {"name": "LB21"} request_mock.assert_called_with( - url="/load_balancer_types", method="GET", params=params + method="GET", + url="/load_balancer_types", + params=params, ) assert load_balancer_type._client is load_balancer_types_client diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index a2bc4388..a41a2c3b 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -46,9 +46,13 @@ def test_get_actions_list( params, ): request_mock.return_value = response_get_actions + result = bound_load_balancer.get_actions_list(**params) + request_mock.assert_called_with( - url="/load_balancers/14/actions", method="GET", params=params + method="GET", + url="/load_balancers/14/actions", + params=params, ) actions = result.actions @@ -70,12 +74,15 @@ def test_get_actions( params, ): request_mock.return_value = response_get_actions + actions = bound_load_balancer.get_actions(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/load_balancers/14/actions", method="GET", params=params + method="GET", + url="/load_balancers/14/actions", + params=params, ) assert len(actions) == 1 @@ -91,10 +98,12 @@ def test_update( response_update_load_balancer, ): request_mock.return_value = response_update_load_balancer + load_balancer = bound_load_balancer.update(name="new-name", labels={}) + request_mock.assert_called_with( - url="/load_balancers/14", method="PUT", + url="/load_balancers/14", json={"name": "new-name", "labels": {}}, ) @@ -108,8 +117,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_load_balancer.delete() - request_mock.assert_called_with(url="/load_balancers/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/load_balancers/14", + ) assert delete_success is True @@ -120,14 +134,16 @@ def test_get_metrics( response_get_metrics, ): request_mock.return_value = response_get_metrics + response = bound_load_balancer.get_metrics( type=["requests_per_second"], start="2023-12-14T16:55:32+01:00", end="2023-12-14T16:55:32+01:00", ) + request_mock.assert_called_with( - url="/load_balancers/14/metrics", method="GET", + url="/load_balancers/14/metrics", params={ "type": "requests_per_second", "start": "2023-12-14T16:55:32+01:00", @@ -144,12 +160,14 @@ def test_add_service( response_add_service, ): request_mock.return_value = response_add_service + service = LoadBalancerService(listen_port=80, protocol="http") action = bound_load_balancer.add_service(service) + request_mock.assert_called_with( - json={"protocol": "http", "listen_port": 80}, - url="/load_balancers/14/actions/add_service", method="POST", + url="/load_balancers/14/actions/add_service", + json={"protocol": "http", "listen_port": 80}, ) assert action.id == 13 @@ -163,12 +181,14 @@ def test_delete_service( response_delete_service, ): request_mock.return_value = response_delete_service + service = LoadBalancerService(listen_port=12) action = bound_load_balancer.delete_service(service) + request_mock.assert_called_with( - json={"listen_port": 12}, - url="/load_balancers/14/actions/delete_service", method="POST", + url="/load_balancers/14/actions/delete_service", + json={"listen_port": 12}, ) assert action.id == 13 @@ -206,10 +226,14 @@ def test_add_target( params, ): request_mock.return_value = response_add_target + action = bound_load_balancer.add_target(target) params.update({"type": target.type}) + request_mock.assert_called_with( - url="/load_balancers/14/actions/add_target", method="POST", json=params + method="POST", + url="/load_balancers/14/actions/add_target", + json=params, ) assert action.id == 13 @@ -247,10 +271,14 @@ def test_remove_target( params, ): request_mock.return_value = response_remove_target + action = bound_load_balancer.remove_target(target) params.update({"type": target.type}) + request_mock.assert_called_with( - url="/load_balancers/14/actions/remove_target", method="POST", json=params + method="POST", + url="/load_balancers/14/actions/remove_target", + json=params, ) assert action.id == 13 @@ -264,13 +292,17 @@ def test_update_service( response_update_service, ): request_mock.return_value = response_update_service + new_health_check = LoadBalancerHealthCheck( protocol="http", port=13, interval=1, timeout=1, retries=1 ) service = LoadBalancerService(listen_port=12, health_check=new_health_check) action = bound_load_balancer.update_service(service) + request_mock.assert_called_with( + method="POST", + url="/load_balancers/14/actions/update_service", json={ "listen_port": 12, "health_check": { @@ -281,8 +313,6 @@ def test_update_service( "retries": 1, }, }, - url="/load_balancers/14/actions/update_service", - method="POST", ) assert action.id == 13 @@ -296,12 +326,14 @@ def test_change_algorithm( response_change_algorithm, ): request_mock.return_value = response_change_algorithm + algorithm = LoadBalancerAlgorithm(type="round_robin") action = bound_load_balancer.change_algorithm(algorithm) + request_mock.assert_called_with( - json={"type": "round_robin"}, - url="/load_balancers/14/actions/change_algorithm", method="POST", + url="/load_balancers/14/actions/change_algorithm", + json={"type": "round_robin"}, ) assert action.id == 13 @@ -315,13 +347,15 @@ def test_change_dns_ptr( response_change_reverse_dns_entry, ): request_mock.return_value = response_change_reverse_dns_entry + action = bound_load_balancer.change_dns_ptr( ip="1.2.3.4", dns_ptr="lb1.example.com" ) + request_mock.assert_called_with( - json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, - url="/load_balancers/14/actions/change_dns_ptr", method="POST", + url="/load_balancers/14/actions/change_dns_ptr", + json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, ) assert action.id == 13 @@ -335,11 +369,13 @@ def test_change_protection( response_change_protection, ): request_mock.return_value = response_change_protection + action = bound_load_balancer.change_protection(delete=True) + request_mock.assert_called_with( - json={"delete": True}, - url="/load_balancers/14/actions/change_protection", method="POST", + url="/load_balancers/14/actions/change_protection", + json={"delete": True}, ) assert action.id == 13 @@ -353,9 +389,12 @@ def test_enable_public_interface( response_enable_public_interface, ): request_mock.return_value = response_enable_public_interface + action = bound_load_balancer.enable_public_interface() + request_mock.assert_called_with( - url="/load_balancers/14/actions/enable_public_interface", method="POST" + method="POST", + url="/load_balancers/14/actions/enable_public_interface", ) assert action.id == 13 @@ -369,9 +408,12 @@ def test_disable_public_interface( response_disable_public_interface, ): request_mock.return_value = response_disable_public_interface + action = bound_load_balancer.disable_public_interface() + request_mock.assert_called_with( - url="/load_balancers/14/actions/disable_public_interface", method="POST" + method="POST", + url="/load_balancers/14/actions/disable_public_interface", ) assert action.id == 13 @@ -385,11 +427,13 @@ def test_attach_to_network( response_attach_load_balancer_to_network, ): request_mock.return_value = response_attach_load_balancer_to_network + action = bound_load_balancer.attach_to_network(Network(id=1)) + request_mock.assert_called_with( - json={"network": 1}, - url="/load_balancers/14/actions/attach_to_network", method="POST", + url="/load_balancers/14/actions/attach_to_network", + json={"network": 1}, ) assert action.id == 13 @@ -403,11 +447,13 @@ def test_detach_from_network( response_detach_from_network, ): request_mock.return_value = response_detach_from_network + action = bound_load_balancer.detach_from_network(Network(id=1)) + request_mock.assert_called_with( - json={"network": 1}, - url="/load_balancers/14/actions/detach_from_network", method="POST", + url="/load_balancers/14/actions/detach_from_network", + json={"network": 1}, ) assert action.id == 13 @@ -421,10 +467,12 @@ def test_change_type( generic_action, ): request_mock.return_value = generic_action + action = bound_load_balancer.change_type(LoadBalancerType(name="lb21")) + request_mock.assert_called_with( - url="/load_balancers/14/actions/change_type", method="POST", + url="/load_balancers/14/actions/change_type", json={"load_balancer_type": "lb21"}, ) @@ -444,8 +492,13 @@ def test_get_by_id( response_load_balancer, ): request_mock.return_value = response_load_balancer + bound_load_balancer = load_balancers_client.get_by_id(1) - request_mock.assert_called_with(url="/load_balancers/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/load_balancers/1", + ) assert bound_load_balancer._client is load_balancers_client assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" @@ -474,9 +527,13 @@ def test_get_list( params, ): request_mock.return_value = response_simple_load_balancers + result = load_balancers_client.get_list(**params) + request_mock.assert_called_with( - url="/load_balancers", method="GET", params=params + method="GET", + url="/load_balancers", + params=params, ) bound_load_balancers = result.load_balancers @@ -506,12 +563,15 @@ def test_get_all( params, ): request_mock.return_value = response_simple_load_balancers + bound_load_balancers = load_balancers_client.get_all(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/load_balancers", method="GET", params=params + method="GET", + url="/load_balancers", + params=params, ) assert len(bound_load_balancers) == 2 @@ -534,12 +594,15 @@ def test_get_by_name( response_simple_load_balancers, ): request_mock.return_value = response_simple_load_balancers + bound_load_balancer = load_balancers_client.get_by_name("Web Frontend") params = {"name": "Web Frontend"} request_mock.assert_called_with( - url="/load_balancers", method="GET", params=params + method="GET", + url="/load_balancers", + params=params, ) assert bound_load_balancer._client is load_balancers_client @@ -553,14 +616,16 @@ def test_create( response_create_load_balancer, ): request_mock.return_value = response_create_load_balancer + response = load_balancers_client.create( "my-balancer", load_balancer_type=LoadBalancerType(name="lb11"), location=Location(id=1), ) + request_mock.assert_called_with( - url="/load_balancers", method="POST", + url="/load_balancers", json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, ) @@ -582,12 +647,14 @@ def test_change_type_with_load_balancer_type_name( generic_action, ): request_mock.return_value = generic_action + action = load_balancers_client.change_type( load_balancer, LoadBalancerType(name="lb11") ) + request_mock.assert_called_with( - url="/load_balancers/1/actions/change_type", method="POST", + url="/load_balancers/1/actions/change_type", json={"load_balancer_type": "lb11"}, ) @@ -606,12 +673,14 @@ def test_change_type_with_load_balancer_type_id( generic_action, ): request_mock.return_value = generic_action + action = load_balancers_client.change_type( load_balancer, LoadBalancerType(id=1) ) + request_mock.assert_called_with( - url="/load_balancers/1/actions/change_type", method="POST", + url="/load_balancers/1/actions/change_type", json={"load_balancer_type": 1}, ) @@ -627,7 +696,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = load_balancers_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/load_balancers/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/load_balancers/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == load_balancers_client._parent.actions @@ -641,11 +713,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = load_balancers_client.actions.get_list() request_mock.assert_called_with( - url="/load_balancers/actions", method="GET", + url="/load_balancers/actions", params={}, ) @@ -665,11 +738,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = load_balancers_client.actions.get_all() request_mock.assert_called_with( - url="/load_balancers/actions", method="GET", + url="/load_balancers/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index 9b1b643b..61150c42 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -20,8 +20,13 @@ def test_get_by_id( location_response, ): request_mock.return_value = location_response + location = locations_client.get_by_id(1) - request_mock.assert_called_with(url="/locations/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/locations/1", + ) assert location._client is locations_client assert location.id == 1 assert location.name == "fsn1" @@ -38,8 +43,14 @@ def test_get_list( params, ): request_mock.return_value = two_locations_response + result = locations_client.get_list(**params) - request_mock.assert_called_with(url="/locations", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, + ) locations = result.locations assert result.meta is not None @@ -68,11 +79,16 @@ def test_get_all( params, ): request_mock.return_value = two_locations_response + locations = locations_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/locations", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, + ) assert len(locations) == 2 @@ -96,11 +112,16 @@ def test_get_by_name( one_locations_response, ): request_mock.return_value = one_locations_response + location = locations_client.get_by_name("fsn1") params = {"name": "fsn1"} - request_mock.assert_called_with(url="/locations", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, + ) assert location._client is locations_client assert location.id == 1 diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index ae98f485..da2ee792 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -58,10 +58,12 @@ def test_get_actions( response_get_actions, ): request_mock.return_value = response_get_actions + actions = bound_network.get_actions(sort="id") + request_mock.assert_called_with( - url="/networks/14/actions", method="GET", + url="/networks/14/actions", params={"page": 1, "per_page": 50, "sort": "id"}, ) @@ -78,9 +80,13 @@ def test_update( response_update_network, ): request_mock.return_value = response_update_network + network = bound_network.update(name="new-name") + request_mock.assert_called_with( - url="/networks/14", method="PUT", json={"name": "new-name"} + method="PUT", + url="/networks/14", + json={"name": "new-name"}, ) assert network.id == 4711 @@ -93,8 +99,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_network.delete() - request_mock.assert_called_with(url="/networks/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/networks/14", + ) assert delete_success is True @@ -105,10 +116,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_network.change_protection(True) + request_mock.assert_called_with( - url="/networks/14/actions/change_protection", method="POST", + url="/networks/14/actions/change_protection", json={"delete": True}, ) @@ -122,15 +135,17 @@ def test_add_subnet( generic_action, ): request_mock.return_value = generic_action + subnet = NetworkSubnet( type=NetworkSubnet.TYPE_CLOUD, ip_range="10.0.1.0/24", network_zone="eu-central", ) action = bound_network.add_subnet(subnet) + request_mock.assert_called_with( - url="/networks/14/actions/add_subnet", method="POST", + url="/networks/14/actions/add_subnet", json={ "type": NetworkSubnet.TYPE_CLOUD, "ip_range": "10.0.1.0/24", @@ -148,11 +163,13 @@ def test_delete_subnet( generic_action, ): request_mock.return_value = generic_action + subnet = NetworkSubnet(ip_range="10.0.1.0/24") action = bound_network.delete_subnet(subnet) + request_mock.assert_called_with( - url="/networks/14/actions/delete_subnet", method="POST", + url="/networks/14/actions/delete_subnet", json={"ip_range": "10.0.1.0/24"}, ) @@ -166,11 +183,13 @@ def test_add_route( generic_action, ): request_mock.return_value = generic_action + route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.add_route(route) + request_mock.assert_called_with( - url="/networks/14/actions/add_route", method="POST", + url="/networks/14/actions/add_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -184,11 +203,13 @@ def test_delete_route( generic_action, ): request_mock.return_value = generic_action + route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.delete_route(route) + request_mock.assert_called_with( - url="/networks/14/actions/delete_route", method="POST", + url="/networks/14/actions/delete_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -202,10 +223,12 @@ def test_change_ip( generic_action, ): request_mock.return_value = generic_action + action = bound_network.change_ip_range("10.0.0.0/12") + request_mock.assert_called_with( - url="/networks/14/actions/change_ip_range", method="POST", + url="/networks/14/actions/change_ip_range", json={"ip_range": "10.0.0.0/12"}, ) @@ -246,8 +269,13 @@ def test_get_by_id( network_response, ): request_mock.return_value = network_response + bound_network = networks_client.get_by_id(1) - request_mock.assert_called_with(url="/networks/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/networks/1", + ) assert bound_network._client is networks_client assert bound_network.id == 1 assert bound_network.name == "mynet" @@ -264,8 +292,14 @@ def test_get_list( params, ): request_mock.return_value = two_networks_response + result = networks_client.get_list(**params) - request_mock.assert_called_with(url="/networks", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, + ) bound_networks = result.networks assert result.meta is not None @@ -292,11 +326,16 @@ def test_get_all( params, ): request_mock.return_value = two_networks_response + bound_networks = networks_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/networks", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, + ) assert len(bound_networks) == 2 @@ -318,11 +357,16 @@ def test_get_by_name( one_network_response, ): request_mock.return_value = one_network_response + bound_network = networks_client.get_by_name("mynet") params = {"name": "mynet"} - request_mock.assert_called_with(url="/networks", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, + ) assert bound_network._client is networks_client assert bound_network.id == 1 @@ -335,10 +379,12 @@ def test_create( network_create_response, ): request_mock.return_value = network_create_response + networks_client.create(name="mynet", ip_range="10.0.0.0/8") + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={"name": "mynet", "ip_range": "10.0.0.0/8"}, ) @@ -354,9 +400,10 @@ def test_create_with_expose_routes_to_vswitch( networks_client.create( name="mynet", ip_range="10.0.0.0/8", expose_routes_to_vswitch=True ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -372,12 +419,14 @@ def test_create_with_subnet( network_create_response, ): request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -399,14 +448,16 @@ def test_create_with_subnet_vswitch( network_create_response, ): request_mock.return_value = network_create_response + network_subnet.type = NetworkSubnet.TYPE_VSWITCH network_subnet.vswitch_id = 1000 networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -429,12 +480,14 @@ def test_create_with_route( network_create_response, ): request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", routes=[network_route] ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -458,9 +511,10 @@ def test_create_with_route_and_expose_routes_to_vswitch( routes=[network_route], expose_routes_to_vswitch=True, ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -478,15 +532,17 @@ def test_create_with_route_and_subnet( network_create_response, ): request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet], routes=[network_route], ) + request_mock.assert_called_with( - url="/networks", method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -512,9 +568,13 @@ def test_get_actions_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = networks_client.get_actions_list(network, sort="id") + request_mock.assert_called_with( - url="/networks/1/actions", method="GET", params={"sort": "id"} + method="GET", + url="/networks/1/actions", + params={"sort": "id"}, ) actions = result.actions @@ -536,12 +596,14 @@ def test_update( response_update_network, ): request_mock.return_value = response_update_network + network = networks_client.update( network, name="new-name", expose_routes_to_vswitch=True ) + request_mock.assert_called_with( - url="/networks/1", method="PUT", + url="/networks/1", json={"name": "new-name", "expose_routes_to_vswitch": True}, ) @@ -560,10 +622,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = networks_client.change_protection(network, True) + request_mock.assert_called_with( - url="/networks/1/actions/change_protection", method="POST", + url="/networks/1/actions/change_protection", json={"delete": True}, ) @@ -581,8 +645,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = networks_client.delete(network) - request_mock.assert_called_with(url="/networks/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/networks/1", + ) assert delete_success is True @@ -600,9 +669,10 @@ def test_add_subnet( request_mock.return_value = generic_action action = networks_client.add_subnet(network, network_subnet) + request_mock.assert_called_with( - url="/networks/1/actions/add_subnet", method="POST", + url="/networks/1/actions/add_subnet", json={ "type": NetworkSubnet.TYPE_CLOUD, "ip_range": "10.0.1.0/24", @@ -627,9 +697,10 @@ def test_add_subnet_vswitch( request_mock.return_value = generic_action action = networks_client.add_subnet(network, network_vswitch_subnet) + request_mock.assert_called_with( - url="/networks/1/actions/add_subnet", method="POST", + url="/networks/1/actions/add_subnet", json={ "type": NetworkSubnet.TYPE_VSWITCH, "ip_range": "10.0.1.0/24", @@ -655,9 +726,10 @@ def test_delete_subnet( request_mock.return_value = generic_action action = networks_client.delete_subnet(network, network_subnet) + request_mock.assert_called_with( - url="/networks/1/actions/delete_subnet", method="POST", + url="/networks/1/actions/delete_subnet", json={"ip_range": "10.0.1.0/24"}, ) @@ -678,9 +750,10 @@ def test_add_route( request_mock.return_value = generic_action action = networks_client.add_route(network, network_route) + request_mock.assert_called_with( - url="/networks/1/actions/add_route", method="POST", + url="/networks/1/actions/add_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -701,9 +774,10 @@ def test_delete_route( request_mock.return_value = generic_action action = networks_client.delete_route(network, network_route) + request_mock.assert_called_with( - url="/networks/1/actions/delete_route", method="POST", + url="/networks/1/actions/delete_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -721,10 +795,12 @@ def test_change_ip_range( generic_action, ): request_mock.return_value = generic_action + action = networks_client.change_ip_range(network, "10.0.0.0/12") + request_mock.assert_called_with( - url="/networks/1/actions/change_ip_range", method="POST", + url="/networks/1/actions/change_ip_range", json={"ip_range": "10.0.0.0/12"}, ) @@ -740,7 +816,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = networks_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/networks/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/networks/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == networks_client._parent.actions @@ -754,11 +833,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = networks_client.actions.get_list() request_mock.assert_called_with( - url="/networks/actions", method="GET", + url="/networks/actions", params={}, ) @@ -778,11 +858,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = networks_client.actions.get_all() request_mock.assert_called_with( - url="/networks/actions", method="GET", + url="/networks/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 824c50bd..fc1f2d57 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -37,15 +37,17 @@ def test_update( placement_group_response, ): request_mock.return_value = placement_group_response + placement_group = bound_placement_group.update( name=placement_group_response["placement_group"]["name"], labels=placement_group_response["placement_group"]["labels"], ) + request_mock.assert_called_with( + method="PUT", url="/placement_groups/{placement_group_id}".format( placement_group_id=placement_group_response["placement_group"]["id"] ), - method="PUT", json={ "labels": placement_group_response["placement_group"]["labels"], "name": placement_group_response["placement_group"]["name"], @@ -60,7 +62,11 @@ def test_delete( bound_placement_group, ): delete_success = bound_placement_group.delete() - request_mock.assert_called_with(url="/placement_groups/897", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/placement_groups/897", + ) assert delete_success is True @@ -77,14 +83,16 @@ def test_get_by_id( placement_group_response, ): request_mock.return_value = placement_group_response + placement_group = placement_groups_client.get_by_id( placement_group_response["placement_group"]["id"] ) + request_mock.assert_called_with( + method="GET", url="/placement_groups/{placement_group_id}".format( placement_group_id=placement_group_response["placement_group"]["id"] ), - method="GET", ) assert placement_group._client is placement_groups_client @@ -113,9 +121,13 @@ def test_get_list( params, ): request_mock.return_value = two_placement_groups_response + result = placement_groups_client.get_list(**params) + request_mock.assert_called_with( - url="/placement_groups", method="GET", params=params + method="GET", + url="/placement_groups", + params=params, ) placement_groups = result.placement_groups @@ -151,11 +163,15 @@ def test_get_all( params, ): request_mock.return_value = two_placement_groups_response + placement_groups = placement_groups_client.get_all(**params) params.update({"page": 1, "per_page": 50}) + request_mock.assert_called_with( - url="/placement_groups", method="GET", params=params + method="GET", + url="/placement_groups", + params=params, ) assert len(placement_groups) == len( @@ -176,13 +192,17 @@ def test_get_by_name( one_placement_group_response, ): request_mock.return_value = one_placement_group_response + placement_group = placement_groups_client.get_by_name( one_placement_group_response["placement_groups"][0]["name"] ) params = {"name": one_placement_group_response["placement_groups"][0]["name"]} + request_mock.assert_called_with( - url="/placement_groups", method="GET", params=params + method="GET", + url="/placement_groups", + params=params, ) check_variables( @@ -196,6 +216,7 @@ def test_create( response_create_placement_group, ): request_mock.return_value = response_create_placement_group + response = placement_groups_client.create( name=response_create_placement_group["placement_group"]["name"], type=response_create_placement_group["placement_group"]["type"], @@ -207,8 +228,11 @@ def test_create( "labels": response_create_placement_group["placement_group"]["labels"], "type": response_create_placement_group["placement_group"]["type"], } + request_mock.assert_called_with( - url="/placement_groups", method="POST", json=json + method="POST", + url="/placement_groups", + json=json, ) bound_placement_group = response.placement_group diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 33b74669..b7b3c07c 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -47,10 +47,12 @@ def test_update( response_update_primary_ip, ): request_mock.return_value = response_update_primary_ip + primary_ip = bound_primary_ip.update(auto_delete=True, name="my-resource") + request_mock.assert_called_with( - url="/primary_ips/14", method="PUT", + url="/primary_ips/14", json={"auto_delete": True, "name": "my-resource"}, ) @@ -64,8 +66,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_primary_ip.delete() - request_mock.assert_called_with(url="/primary_ips/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/primary_ips/14", + ) assert delete_success is True @@ -76,10 +83,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_primary_ip.change_protection(True) + request_mock.assert_called_with( - url="/primary_ips/14/actions/change_protection", method="POST", + url="/primary_ips/14/actions/change_protection", json={"delete": True}, ) @@ -93,10 +102,12 @@ def test_assign( generic_action, ): request_mock.return_value = generic_action + action = bound_primary_ip.assign(assignee_id=12, assignee_type="server") + request_mock.assert_called_with( - url="/primary_ips/14/actions/assign", method="POST", + url="/primary_ips/14/actions/assign", json={"assignee_id": 12, "assignee_type": "server"}, ) assert action.id == 1 @@ -109,9 +120,12 @@ def test_unassign( generic_action, ): request_mock.return_value = generic_action + action = bound_primary_ip.unassign() + request_mock.assert_called_with( - url="/primary_ips/14/actions/unassign", method="POST" + method="POST", + url="/primary_ips/14/actions/unassign", ) assert action.id == 1 assert action.progress == 0 @@ -123,10 +137,12 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = bound_primary_ip.change_dns_ptr("1.2.3.4", "server02.example.com") + request_mock.assert_called_with( - url="/primary_ips/14/actions/change_dns_ptr", method="POST", + url="/primary_ips/14/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 @@ -145,8 +161,13 @@ def test_get_by_id( primary_ip_response, ): request_mock.return_value = primary_ip_response + bound_primary_ip = primary_ips_client.get_by_id(1) - request_mock.assert_called_with(url="/primary_ips/1", method="GET") + + 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 @@ -157,9 +178,13 @@ def test_get_by_name( one_primary_ips_response, ): request_mock.return_value = one_primary_ips_response + bound_primary_ip = primary_ips_client.get_by_name("my-resource") + request_mock.assert_called_with( - url="/primary_ips", method="GET", params={"name": "my-resource"} + method="GET", + url="/primary_ips", + params={"name": "my-resource"}, ) assert bound_primary_ip._client is primary_ips_client assert bound_primary_ip.id == 42 @@ -174,11 +199,16 @@ def test_get_all( params, ): request_mock.return_value = all_primary_ips_response + bound_primary_ips = primary_ips_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/primary_ips", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params=params, + ) assert len(bound_primary_ips) == 1 @@ -195,12 +225,14 @@ def test_create_with_datacenter( primary_ip_response, ): request_mock.return_value = primary_ip_response + response = primary_ips_client.create( type="ipv6", name="my-resource", datacenter=Datacenter(name="datacenter") ) + request_mock.assert_called_with( - url="/primary_ips", method="POST", + url="/primary_ips", json={ "name": "my-resource", "type": "ipv6", @@ -225,15 +257,17 @@ def test_create_with_assignee_id( primary_ip_create_response, ): request_mock.return_value = primary_ip_create_response + response = primary_ips_client.create( type="ipv6", name="my-ip", assignee_id=17, assignee_type="server", ) + request_mock.assert_called_with( - url="/primary_ips", method="POST", + url="/primary_ips", json={ "name": "my-ip", "type": "ipv6", @@ -262,12 +296,14 @@ def test_update( response_update_primary_ip, ): request_mock.return_value = response_update_primary_ip + primary_ip = primary_ips_client.update( primary_ip, auto_delete=True, name="my-resource" ) + request_mock.assert_called_with( - url="/primary_ips/1", method="PUT", + url="/primary_ips/1", json={"auto_delete": True, "name": "my-resource"}, ) @@ -286,10 +322,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = primary_ips_client.change_protection(primary_ip, True) + request_mock.assert_called_with( - url="/primary_ips/1/actions/change_protection", method="POST", + url="/primary_ips/1/actions/change_protection", json={"delete": True}, ) @@ -307,8 +345,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = primary_ips_client.delete(primary_ip) - request_mock.assert_called_with(url="/primary_ips/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/primary_ips/1", + ) assert delete_success is True @@ -329,10 +372,12 @@ def test_assign( generic_action, ): request_mock.return_value = generic_action + action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) + request_mock.assert_called_with( - url="/primary_ips/12/actions/assign", method="POST", + url="/primary_ips/12/actions/assign", json={"assignee_id": 1, "assignee_type": "server"}, ) assert action.id == 1 @@ -349,9 +394,12 @@ def test_unassign( generic_action, ): request_mock.return_value = generic_action + action = primary_ips_client.unassign(primary_ip) + request_mock.assert_called_with( - url="/primary_ips/12/actions/unassign", method="POST" + method="POST", + url="/primary_ips/12/actions/unassign", ) assert action.id == 1 assert action.progress == 0 @@ -367,12 +415,14 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = primary_ips_client.change_dns_ptr( primary_ip, "1.2.3.4", "server02.example.com" ) + request_mock.assert_called_with( - url="/primary_ips/12/actions/change_dns_ptr", method="POST", + url="/primary_ips/12/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 @@ -387,7 +437,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = primary_ips_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/primary_ips/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/primary_ips/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == primary_ips_client._parent.actions @@ -401,11 +454,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = primary_ips_client.actions.get_list() request_mock.assert_called_with( - url="/primary_ips/actions", method="GET", + url="/primary_ips/actions", params={}, ) @@ -425,11 +479,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = primary_ips_client.actions.get_all() request_mock.assert_called_with( - url="/primary_ips/actions", method="GET", + url="/primary_ips/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 92c9bef7..7917746f 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -52,8 +52,13 @@ def test_get_by_id( server_type_response, ): request_mock.return_value = server_type_response + server_type = server_types_client.get_by_id(1) - request_mock.assert_called_with(url="/server_types/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/server_types/1", + ) assert server_type._client is server_types_client assert server_type.id == 1 assert server_type.name == "cx11" @@ -69,9 +74,13 @@ def test_get_list( params, ): request_mock.return_value = two_server_types_response + result = server_types_client.get_list(**params) + request_mock.assert_called_with( - url="/server_types", method="GET", params=params + method="GET", + url="/server_types", + params=params, ) server_types = result.server_types @@ -99,12 +108,15 @@ def test_get_all( params, ): request_mock.return_value = two_server_types_response + server_types = server_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/server_types", method="GET", params=params + method="GET", + url="/server_types", + params=params, ) assert len(server_types) == 2 @@ -127,12 +139,15 @@ def test_get_by_name( one_server_types_response, ): request_mock.return_value = one_server_types_response + server_type = server_types_client.get_by_name("cx11") params = {"name": "cx11"} request_mock.assert_called_with( - url="/server_types", method="GET", params=params + method="GET", + url="/server_types", + params=params, ) assert server_type._client is server_types_client diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index cb785b0c..7b496c51 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -143,9 +143,13 @@ def test_get_actions_list( params, ): request_mock.return_value = response_get_actions + result = bound_server.get_actions_list(**params) + request_mock.assert_called_with( - url="/servers/14/actions", method="GET", params=params + method="GET", + url="/servers/14/actions", + params=params, ) actions = result.actions @@ -169,12 +173,15 @@ def test_get_actions( params, ): request_mock.return_value = response_get_actions + actions = bound_server.get_actions(**params) params.update({"page": 1, "per_page": 50}) request_mock.assert_called_with( - url="/servers/14/actions", method="GET", params=params + method="GET", + url="/servers/14/actions", + params=params, ) assert len(actions) == 1 @@ -190,9 +197,13 @@ def test_update( response_update_server, ): request_mock.return_value = response_update_server + server = bound_server.update(name="new-name", labels={}) + request_mock.assert_called_with( - url="/servers/14", method="PUT", json={"name": "new-name", "labels": {}} + method="PUT", + url="/servers/14", + json={"name": "new-name", "labels": {}}, ) assert server.id == 14 @@ -205,8 +216,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + action = bound_server.delete() - request_mock.assert_called_with(url="/servers/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/servers/14", + ) assert action.id == 1 assert action.progress == 0 @@ -218,14 +234,16 @@ def test_get_metrics( response_get_metrics, ): request_mock.return_value = response_get_metrics + response = bound_server.get_metrics( type=["cpu", "disk"], start="2023-12-14T17:40:00+01:00", end="2023-12-14T17:50:00+01:00", ) + request_mock.assert_called_with( - url="/servers/14/metrics", method="GET", + url="/servers/14/metrics", params={ "type": "cpu,disk", "start": "2023-12-14T17:40:00+01:00", @@ -244,9 +262,12 @@ def test_power_off( generic_action, ): request_mock.return_value = generic_action + action = bound_server.power_off() + request_mock.assert_called_with( - url="/servers/14/actions/poweroff", method="POST" + method="POST", + url="/servers/14/actions/poweroff", ) assert action.id == 1 @@ -259,9 +280,12 @@ def test_power_on( generic_action, ): request_mock.return_value = generic_action + action = bound_server.power_on() + request_mock.assert_called_with( - url="/servers/14/actions/poweron", method="POST" + method="POST", + url="/servers/14/actions/poweron", ) assert action.id == 1 @@ -274,8 +298,13 @@ def test_reboot( generic_action, ): request_mock.return_value = generic_action + action = bound_server.reboot() - request_mock.assert_called_with(url="/servers/14/actions/reboot", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/servers/14/actions/reboot", + ) assert action.id == 1 assert action.progress == 0 @@ -287,8 +316,13 @@ def test_reset( generic_action, ): request_mock.return_value = generic_action + action = bound_server.reset() - request_mock.assert_called_with(url="/servers/14/actions/reset", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/servers/14/actions/reset", + ) assert action.id == 1 assert action.progress == 0 @@ -300,9 +334,12 @@ def test_shutdown( generic_action, ): request_mock.return_value = generic_action + action = bound_server.shutdown() + request_mock.assert_called_with( - url="/servers/14/actions/shutdown", method="POST" + method="POST", + url="/servers/14/actions/shutdown", ) assert action.id == 1 @@ -315,9 +352,12 @@ def test_reset_password( response_server_reset_password, ): request_mock.return_value = response_server_reset_password + response = bound_server.reset_password() + request_mock.assert_called_with( - url="/servers/14/actions/reset_password", method="POST" + method="POST", + url="/servers/14/actions/reset_password", ) assert response.action.id == 1 @@ -331,10 +371,12 @@ def test_change_type( generic_action, ): request_mock.return_value = generic_action + action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) + request_mock.assert_called_with( - url="/servers/14/actions/change_type", method="POST", + url="/servers/14/actions/change_type", json={"server_type": "cx11", "upgrade_disk": True}, ) @@ -348,10 +390,12 @@ def test_enable_rescue( response_server_enable_rescue, ): request_mock.return_value = response_server_enable_rescue + response = bound_server.enable_rescue(type="linux64") + request_mock.assert_called_with( - url="/servers/14/actions/enable_rescue", method="POST", + url="/servers/14/actions/enable_rescue", json={"type": "linux64"}, ) @@ -366,9 +410,12 @@ def test_disable_rescue( generic_action, ): request_mock.return_value = generic_action + action = bound_server.disable_rescue() + request_mock.assert_called_with( - url="/servers/14/actions/disable_rescue", method="POST" + method="POST", + url="/servers/14/actions/disable_rescue", ) assert action.id == 1 @@ -381,10 +428,12 @@ def test_create_image( response_server_create_image, ): request_mock.return_value = response_server_create_image + response = bound_server.create_image(description="my image", type="snapshot") + request_mock.assert_called_with( - url="/servers/14/actions/create_image", method="POST", + url="/servers/14/actions/create_image", json={"description": "my image", "type": "snapshot"}, ) @@ -399,13 +448,15 @@ def test_rebuild( generic_action, ): request_mock.return_value = generic_action + response = bound_server.rebuild( Image(name="ubuntu-20.04"), return_response=True, ) + request_mock.assert_called_with( - url="/servers/14/actions/rebuild", method="POST", + url="/servers/14/actions/rebuild", json={"image": "ubuntu-20.04"}, ) @@ -420,9 +471,12 @@ def test_enable_backup( generic_action, ): request_mock.return_value = generic_action + action = bound_server.enable_backup() + request_mock.assert_called_with( - url="/servers/14/actions/enable_backup", method="POST" + method="POST", + url="/servers/14/actions/enable_backup", ) assert action.id == 1 @@ -435,9 +489,12 @@ def test_disable_backup( generic_action, ): request_mock.return_value = generic_action + action = bound_server.disable_backup() + request_mock.assert_called_with( - url="/servers/14/actions/disable_backup", method="POST" + method="POST", + url="/servers/14/actions/disable_backup", ) assert action.id == 1 @@ -450,10 +507,12 @@ def test_attach_iso( generic_action, ): request_mock.return_value = generic_action + action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) + request_mock.assert_called_with( - url="/servers/14/actions/attach_iso", method="POST", + url="/servers/14/actions/attach_iso", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, ) @@ -467,9 +526,12 @@ def test_detach_iso( generic_action, ): request_mock.return_value = generic_action + action = bound_server.detach_iso() + request_mock.assert_called_with( - url="/servers/14/actions/detach_iso", method="POST" + method="POST", + url="/servers/14/actions/detach_iso", ) assert action.id == 1 @@ -482,10 +544,12 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = bound_server.change_dns_ptr("1.2.3.4", "example.com") + request_mock.assert_called_with( - url="/servers/14/actions/change_dns_ptr", method="POST", + url="/servers/14/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, ) @@ -499,10 +563,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_server.change_protection(True, True) + request_mock.assert_called_with( - url="/servers/14/actions/change_protection", method="POST", + url="/servers/14/actions/change_protection", json={"delete": True, "rebuild": True}, ) @@ -516,9 +582,12 @@ def test_request_console( response_server_request_console, ): request_mock.return_value = response_server_request_console + response = bound_server.request_console() + request_mock.assert_called_with( - url="/servers/14/actions/request_console", method="POST" + method="POST", + url="/servers/14/actions/request_console", ) assert response.action.id == 1 @@ -540,12 +609,14 @@ def test_attach_to_network( response_attach_to_network, ): request_mock.return_value = response_attach_to_network + action = bound_server.attach_to_network( network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] ) + request_mock.assert_called_with( - url="/servers/14/actions/attach_to_network", method="POST", + url="/servers/14/actions/attach_to_network", json={ "network": 4711, "ip": "10.0.1.1", @@ -568,10 +639,12 @@ def test_detach_from_network( response_detach_from_network, ): request_mock.return_value = response_detach_from_network + action = bound_server.detach_from_network(network) + request_mock.assert_called_with( - url="/servers/14/actions/detach_from_network", method="POST", + url="/servers/14/actions/detach_from_network", json={"network": 4711}, ) @@ -590,10 +663,12 @@ def test_change_alias_ips( response_change_alias_ips, ): request_mock.return_value = response_change_alias_ips + action = bound_server.change_alias_ips(network, ["10.0.1.2", "10.0.1.3"]) + request_mock.assert_called_with( - url="/servers/14/actions/change_alias_ips", method="POST", + url="/servers/14/actions/change_alias_ips", json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, ) @@ -613,10 +688,12 @@ def test_add_to_placement_group( response_add_to_placement_group, ): request_mock.return_value = response_add_to_placement_group + action = bound_server.add_to_placement_group(placement_group) + request_mock.assert_called_with( - url="/servers/14/actions/add_to_placement_group", method="POST", + url="/servers/14/actions/add_to_placement_group", json={"placement_group": 897}, ) @@ -631,9 +708,12 @@ def test_remove_from_placement_group( response_remove_from_placement_group, ): request_mock.return_value = response_remove_from_placement_group + action = bound_server.remove_from_placement_group() + request_mock.assert_called_with( - url="/servers/14/actions/remove_from_placement_group", method="POST" + method="POST", + url="/servers/14/actions/remove_from_placement_group", ) assert action.id == 13 @@ -653,8 +733,13 @@ def test_get_by_id( response_simple_server, ): request_mock.return_value = response_simple_server + bound_server = servers_client.get_by_id(1) - request_mock.assert_called_with(url="/servers/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/servers/1", + ) assert bound_server._client is servers_client assert bound_server.id == 1 assert bound_server.name == "my-server" @@ -675,8 +760,14 @@ def test_get_list( params, ): request_mock.return_value = response_simple_servers + result = servers_client.get_list(**params) - request_mock.assert_called_with(url="/servers", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, + ) bound_servers = result.servers assert result.meta is not None @@ -705,11 +796,16 @@ def test_get_all( params, ): request_mock.return_value = response_simple_servers + bound_servers = servers_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/servers", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, + ) assert len(bound_servers) == 2 @@ -731,11 +827,16 @@ def test_get_by_name( response_simple_servers, ): request_mock.return_value = response_simple_servers + bound_server = servers_client.get_by_name("my-server") params = {"name": "my-server"} - request_mock.assert_called_with(url="/servers", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, + ) assert bound_server._client is servers_client assert bound_server.id == 1 @@ -748,15 +849,17 @@ def test_create_with_datacenter( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(id=4711), datacenter=Datacenter(id=1), ) + request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -785,15 +888,17 @@ def test_create_with_location( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(name="ubuntu-20.04"), location=Location(name="fsn1"), ) + request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -822,6 +927,7 @@ def test_create_with_volumes( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + volumes = [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -830,9 +936,10 @@ def test_create_with_volumes( volumes=volumes, start_after_create=False, ) + request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -867,6 +974,7 @@ def test_create_with_networks( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + networks = [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -875,9 +983,10 @@ def test_create_with_networks( networks=networks, start_after_create=False, ) + request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -912,6 +1021,7 @@ def test_create_with_firewalls( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + firewalls = [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -920,9 +1030,10 @@ def test_create_with_firewalls( firewalls=firewalls, start_after_create=False, ) + request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -957,6 +1068,7 @@ def test_create_with_placement_group( response_create_simple_server, ): request_mock.return_value = response_create_simple_server + placement_group = PlacementGroup(id=1) response = servers_client.create( "my-server", @@ -967,8 +1079,8 @@ def test_create_with_placement_group( ) request_mock.assert_called_with( - url="/servers", method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -1007,9 +1119,13 @@ def test_get_actions_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = servers_client.get_actions_list(server) + request_mock.assert_called_with( - url="/servers/1/actions", method="GET", params={} + method="GET", + url="/servers/1/actions", + params={}, ) actions = result.actions @@ -1033,9 +1149,13 @@ def test_update( response_update_server, ): request_mock.return_value = response_update_server + server = servers_client.update(server, name="new-name", labels={}) + request_mock.assert_called_with( - url="/servers/1", method="PUT", json={"name": "new-name", "labels": {}} + method="PUT", + url="/servers/1", + json={"name": "new-name", "labels": {}}, ) assert server.id == 14 @@ -1052,8 +1172,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + action = servers_client.delete(server) - request_mock.assert_called_with(url="/servers/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/servers/1", + ) assert action.id == 1 assert action.progress == 0 @@ -1069,9 +1194,12 @@ def test_power_off( generic_action, ): request_mock.return_value = generic_action + action = servers_client.power_off(server) + request_mock.assert_called_with( - url="/servers/1/actions/poweroff", method="POST" + method="POST", + url="/servers/1/actions/poweroff", ) assert action.id == 1 @@ -1088,8 +1216,13 @@ def test_power_on( generic_action, ): request_mock.return_value = generic_action + action = servers_client.power_on(server) - request_mock.assert_called_with(url="/servers/1/actions/poweron", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/poweron", + ) assert action.id == 1 assert action.progress == 0 @@ -1105,8 +1238,13 @@ def test_reboot( generic_action, ): request_mock.return_value = generic_action + action = servers_client.reboot(server) - request_mock.assert_called_with(url="/servers/1/actions/reboot", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/reboot", + ) assert action.id == 1 assert action.progress == 0 @@ -1122,8 +1260,13 @@ def test_reset( generic_action, ): request_mock.return_value = generic_action + action = servers_client.reset(server) - request_mock.assert_called_with(url="/servers/1/actions/reset", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/reset", + ) assert action.id == 1 assert action.progress == 0 @@ -1139,9 +1282,12 @@ def test_shutdown( generic_action, ): request_mock.return_value = generic_action + action = servers_client.shutdown(server) + request_mock.assert_called_with( - url="/servers/1/actions/shutdown", method="POST" + method="POST", + url="/servers/1/actions/shutdown", ) assert action.id == 1 @@ -1158,9 +1304,12 @@ def test_reset_password( response_server_reset_password, ): request_mock.return_value = response_server_reset_password + response = servers_client.reset_password(server) + request_mock.assert_called_with( - url="/servers/1/actions/reset_password", method="POST" + method="POST", + url="/servers/1/actions/reset_password", ) assert response.action.id == 1 @@ -1178,12 +1327,14 @@ def test_change_type_with_server_type_name( generic_action, ): request_mock.return_value = generic_action + action = servers_client.change_type( server, ServerType(name="cx11"), upgrade_disk=True ) + request_mock.assert_called_with( - url="/servers/1/actions/change_type", method="POST", + url="/servers/1/actions/change_type", json={"server_type": "cx11", "upgrade_disk": True}, ) @@ -1201,10 +1352,12 @@ def test_change_type_with_server_type_id( generic_action, ): request_mock.return_value = generic_action + action = servers_client.change_type(server, ServerType(id=1), upgrade_disk=True) + request_mock.assert_called_with( - url="/servers/1/actions/change_type", method="POST", + url="/servers/1/actions/change_type", json={"server_type": 1, "upgrade_disk": True}, ) @@ -1223,6 +1376,7 @@ def test_change_type_with_blank_server_type( with pytest.raises(ValueError) as e: servers_client.change_type(server, ServerType(), upgrade_disk=True) assert str(e.value) == "id or name must be set" + request_mock.assert_not_called() @pytest.mark.parametrize( @@ -1236,10 +1390,12 @@ def test_enable_rescue( response_server_enable_rescue, ): request_mock.return_value = response_server_enable_rescue + response = servers_client.enable_rescue(server, "linux64", [2323]) + request_mock.assert_called_with( - url="/servers/1/actions/enable_rescue", method="POST", + url="/servers/1/actions/enable_rescue", json={"type": "linux64", "ssh_keys": [2323]}, ) @@ -1258,9 +1414,12 @@ def test_disable_rescue( generic_action, ): request_mock.return_value = generic_action + action = servers_client.disable_rescue(server) + request_mock.assert_called_with( - url="/servers/1/actions/disable_rescue", method="POST" + method="POST", + url="/servers/1/actions/disable_rescue", ) assert action.id == 1 @@ -1277,12 +1436,14 @@ def test_create_image( response_server_create_image, ): request_mock.return_value = response_server_create_image + response = servers_client.create_image( server, description="my image", type="snapshot", labels={"key": "value"} ) + request_mock.assert_called_with( - url="/servers/1/actions/create_image", method="POST", + url="/servers/1/actions/create_image", json={ "description": "my image", "type": "snapshot", @@ -1305,14 +1466,16 @@ def test_rebuild( generic_action, ): request_mock.return_value = generic_action + response = servers_client.rebuild( server, Image(name="ubuntu-20.04"), return_response=True, ) + request_mock.assert_called_with( - url="/servers/1/actions/rebuild", method="POST", + url="/servers/1/actions/rebuild", json={"image": "ubuntu-20.04"}, ) @@ -1331,9 +1494,12 @@ def test_enable_backup( generic_action, ): request_mock.return_value = generic_action + action = servers_client.enable_backup(server) + request_mock.assert_called_with( - url="/servers/1/actions/enable_backup", method="POST" + method="POST", + url="/servers/1/actions/enable_backup", ) assert action.id == 1 @@ -1350,9 +1516,12 @@ def test_disable_backup( generic_action, ): request_mock.return_value = generic_action + action = servers_client.disable_backup(server) + request_mock.assert_called_with( - url="/servers/1/actions/disable_backup", method="POST" + method="POST", + url="/servers/1/actions/disable_backup", ) assert action.id == 1 @@ -1369,12 +1538,14 @@ def test_attach_iso( generic_action, ): request_mock.return_value = generic_action + action = servers_client.attach_iso( server, Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1") ) + request_mock.assert_called_with( - url="/servers/1/actions/attach_iso", method="POST", + url="/servers/1/actions/attach_iso", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, ) @@ -1392,9 +1563,12 @@ def test_detach_iso( generic_action, ): request_mock.return_value = generic_action + action = servers_client.detach_iso(server) + request_mock.assert_called_with( - url="/servers/1/actions/detach_iso", method="POST" + method="POST", + url="/servers/1/actions/detach_iso", ) assert action.id == 1 @@ -1411,10 +1585,12 @@ def test_change_dns_ptr( generic_action, ): request_mock.return_value = generic_action + action = servers_client.change_dns_ptr(server, "1.2.3.4", "example.com") + request_mock.assert_called_with( - url="/servers/1/actions/change_dns_ptr", method="POST", + url="/servers/1/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, ) @@ -1432,10 +1608,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = servers_client.change_protection(server, True, True) + request_mock.assert_called_with( - url="/servers/1/actions/change_protection", method="POST", + url="/servers/1/actions/change_protection", json={"delete": True, "rebuild": True}, ) @@ -1453,9 +1631,12 @@ def test_request_console( response_server_request_console, ): request_mock.return_value = response_server_request_console + response = servers_client.request_console(server) + request_mock.assert_called_with( - url="/servers/1/actions/request_console", method="POST" + method="POST", + url="/servers/1/actions/request_console", ) assert response.action.id == 1 @@ -1481,12 +1662,14 @@ def test_attach_to_network( response_attach_to_network, ): request_mock.return_value = response_attach_to_network + action = servers_client.attach_to_network( server, network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] ) + request_mock.assert_called_with( - url="/servers/1/actions/attach_to_network", method="POST", + url="/servers/1/actions/attach_to_network", json={ "network": 4711, "ip": "10.0.1.1", @@ -1513,10 +1696,12 @@ def test_detach_from_network( response_detach_from_network, ): request_mock.return_value = response_detach_from_network + action = servers_client.detach_from_network(server, network) + request_mock.assert_called_with( - url="/servers/1/actions/detach_from_network", method="POST", + url="/servers/1/actions/detach_from_network", json={"network": 4711}, ) @@ -1539,12 +1724,14 @@ def test_change_alias_ips( response_change_alias_ips, ): request_mock.return_value = response_change_alias_ips + action = servers_client.change_alias_ips( server, network, ["10.0.1.2", "10.0.1.3"] ) + request_mock.assert_called_with( - url="/servers/1/actions/change_alias_ips", method="POST", + url="/servers/1/actions/change_alias_ips", json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, ) @@ -1561,7 +1748,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = servers_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/servers/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/servers/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == servers_client._parent.actions @@ -1575,11 +1765,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = servers_client.actions.get_list() request_mock.assert_called_with( - url="/servers/actions", method="GET", + url="/servers/actions", params={}, ) @@ -1599,11 +1790,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = servers_client.actions.get_all() request_mock.assert_called_with( - url="/servers/actions", method="GET", + url="/servers/actions", params={"page": 1, "per_page": 50}, ) diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 5bc90aee..24cf48f7 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -33,9 +33,13 @@ def test_update( response_update_ssh_key, ): request_mock.return_value = response_update_ssh_key + ssh_key = bound_ssh_key.update(name="New name") + request_mock.assert_called_with( - url="/ssh_keys/14", method="PUT", json={"name": "New name"} + method="PUT", + url="/ssh_keys/14", + json={"name": "New name"}, ) assert ssh_key.id == 2323 @@ -48,8 +52,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_ssh_key.delete() - request_mock.assert_called_with(url="/ssh_keys/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/ssh_keys/14", + ) assert delete_success is True @@ -66,8 +75,13 @@ def test_get_by_id( ssh_key_response, ): request_mock.return_value = ssh_key_response + ssh_key = ssh_keys_client.get_by_id(1) - request_mock.assert_called_with(url="/ssh_keys/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys/1", + ) assert ssh_key._client is ssh_keys_client assert ssh_key.id == 2323 assert ssh_key.name == "My ssh key" @@ -94,8 +108,14 @@ def test_get_list( params, ): request_mock.return_value = two_ssh_keys_response + result = ssh_keys_client.get_list(**params) - request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, + ) ssh_keys = result.ssh_keys assert len(ssh_keys) == 2 @@ -122,10 +142,16 @@ def test_get_all( params, ): request_mock.return_value = two_ssh_keys_response + ssh_keys = ssh_keys_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, + ) assert len(ssh_keys) == 2 @@ -147,10 +173,16 @@ def test_get_by_name( one_ssh_keys_response, ): request_mock.return_value = one_ssh_keys_response + ssh_keys = ssh_keys_client.get_by_name("SSH-Key") params = {"name": "SSH-Key"} - request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, + ) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 @@ -163,12 +195,18 @@ def test_get_by_fingerprint( one_ssh_keys_response, ): request_mock.return_value = one_ssh_keys_response + ssh_keys = ssh_keys_client.get_by_fingerprint( "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" ) params = {"fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f"} - request_mock.assert_called_with(url="/ssh_keys", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, + ) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 @@ -181,12 +219,14 @@ def test_create( ssh_key_response, ): request_mock.return_value = ssh_key_response + ssh_key = ssh_keys_client.create( name="My ssh key", public_key="ssh-rsa AAAjjk76kgf...Xt" ) + request_mock.assert_called_with( - url="/ssh_keys", method="POST", + url="/ssh_keys", json={"name": "My ssh key", "public_key": "ssh-rsa AAAjjk76kgf...Xt"}, ) @@ -204,9 +244,13 @@ def test_update( response_update_ssh_key, ): request_mock.return_value = response_update_ssh_key + ssh_key = ssh_keys_client.update(ssh_key, name="New name") + request_mock.assert_called_with( - url="/ssh_keys/1", method="PUT", json={"name": "New name"} + method="PUT", + url="/ssh_keys/1", + json={"name": "New name"}, ) assert ssh_key.id == 2323 @@ -223,7 +267,12 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = ssh_keys_client.delete(ssh_key) - request_mock.assert_called_with(url="/ssh_keys/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/ssh_keys/1", + ) assert delete_success is True diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 1f5f7534..fc0bffa2 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -50,10 +50,12 @@ def test_get_actions( response_get_actions, ): request_mock.return_value = response_get_actions + actions = bound_volume.get_actions(sort="id") + request_mock.assert_called_with( - url="/volumes/14/actions", method="GET", + url="/volumes/14/actions", params={"page": 1, "per_page": 50, "sort": "id"}, ) @@ -70,9 +72,13 @@ def test_update( response_update_volume, ): request_mock.return_value = response_update_volume + volume = bound_volume.update(name="new-name") + request_mock.assert_called_with( - url="/volumes/14", method="PUT", json={"name": "new-name"} + method="PUT", + url="/volumes/14", + json={"name": "new-name"}, ) assert volume.id == 4711 @@ -85,8 +91,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = bound_volume.delete() - request_mock.assert_called_with(url="/volumes/14", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/volumes/14", + ) assert delete_success is True @@ -97,10 +108,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = bound_volume.change_protection(True) + request_mock.assert_called_with( - url="/volumes/14/actions/change_protection", method="POST", + url="/volumes/14/actions/change_protection", json={"delete": True}, ) @@ -118,9 +131,13 @@ def test_attach( generic_action, ): request_mock.return_value = generic_action + action = bound_volume.attach(server) + request_mock.assert_called_with( - url="/volumes/14/actions/attach", method="POST", json={"server": 1} + method="POST", + url="/volumes/14/actions/attach", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 @@ -136,10 +153,12 @@ def test_attach_with_automount( generic_action, ): request_mock.return_value = generic_action + action = bound_volume.attach(server, False) + request_mock.assert_called_with( - url="/volumes/14/actions/attach", method="POST", + url="/volumes/14/actions/attach", json={"server": 1, "automount": False}, ) assert action.id == 1 @@ -152,8 +171,13 @@ def test_detach( generic_action, ): request_mock.return_value = generic_action + action = bound_volume.detach() - request_mock.assert_called_with(url="/volumes/14/actions/detach", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/volumes/14/actions/detach", + ) assert action.id == 1 assert action.progress == 0 @@ -164,9 +188,13 @@ def test_resize( generic_action, ): request_mock.return_value = generic_action + action = bound_volume.resize(50) + request_mock.assert_called_with( - url="/volumes/14/actions/resize", method="POST", json={"size": 50} + method="POST", + url="/volumes/14/actions/resize", + json={"size": 50}, ) assert action.id == 1 assert action.progress == 0 @@ -184,8 +212,13 @@ def test_get_by_id( volume_response, ): request_mock.return_value = volume_response + bound_volume = volumes_client.get_by_id(1) - request_mock.assert_called_with(url="/volumes/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/volumes/1", + ) assert bound_volume._client is volumes_client assert bound_volume.id == 1 assert bound_volume.name == "database-storage" @@ -202,8 +235,14 @@ def test_get_list( params, ): request_mock.return_value = two_volumes_response + result = volumes_client.get_list(**params) - request_mock.assert_called_with(url="/volumes", method="GET", params=params) + + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, + ) bound_volumes = result.volumes assert result.meta is not None @@ -230,11 +269,16 @@ def test_get_all( params, ): request_mock.return_value = two_volumes_response + bound_volumes = volumes_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with(url="/volumes", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, + ) assert len(bound_volumes) == 2 @@ -256,11 +300,16 @@ def test_get_by_name( one_volumes_response, ): request_mock.return_value = one_volumes_response + bound_volume = volumes_client.get_by_name("database-storage") params = {"name": "database-storage"} - request_mock.assert_called_with(url="/volumes", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, + ) assert bound_volume._client is volumes_client assert bound_volume.id == 1 @@ -273,6 +322,7 @@ def test_create_with_location( volume_create_response, ): request_mock.return_value = volume_create_response + response = volumes_client.create( 100, "database-storage", @@ -280,9 +330,10 @@ def test_create_with_location( automount=False, format="xfs", ) + request_mock.assert_called_with( - url="/volumes", method="POST", + url="/volumes", json={ "name": "database-storage", "size": 100, @@ -314,12 +365,14 @@ def test_create_with_server( volume_create_response, ): request_mock.return_value = volume_create_response + volumes_client.create( 100, "database-storage", server=server, automount=False, format="xfs" ) + request_mock.assert_called_with( - url="/volumes", method="POST", + url="/volumes", json={ "name": "database-storage", "size": 100, @@ -339,6 +392,7 @@ def test_create_negative_size( -100, "database-storage", location=Location(name="location") ) assert str(e.value) == "size must be greater than 0" + request_mock.assert_not_called() @pytest.mark.parametrize( @@ -356,6 +410,7 @@ def test_create_wrong_location_server_combination( 100, "database-storage", location=location, server=server ) assert str(e.value) == "only one of server or location must be provided" + request_mock.assert_not_called() @pytest.mark.parametrize( @@ -369,9 +424,13 @@ def test_get_actions_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = volumes_client.get_actions_list(volume, sort="id") + request_mock.assert_called_with( - url="/volumes/1/actions", method="GET", params={"sort": "id"} + method="GET", + url="/volumes/1/actions", + params={"sort": "id"}, ) actions = result.actions @@ -393,9 +452,13 @@ def test_update( response_update_volume, ): request_mock.return_value = response_update_volume + volume = volumes_client.update(volume, name="new-name") + request_mock.assert_called_with( - url="/volumes/1", method="PUT", json={"name": "new-name"} + method="PUT", + url="/volumes/1", + json={"name": "new-name"}, ) assert volume.id == 4711 @@ -412,10 +475,12 @@ def test_change_protection( generic_action, ): request_mock.return_value = generic_action + action = volumes_client.change_protection(volume, True) + request_mock.assert_called_with( - url="/volumes/1/actions/change_protection", method="POST", + url="/volumes/1/actions/change_protection", json={"delete": True}, ) @@ -433,8 +498,13 @@ def test_delete( generic_action, ): request_mock.return_value = generic_action + delete_success = volumes_client.delete(volume) - request_mock.assert_called_with(url="/volumes/1", method="DELETE") + + request_mock.assert_called_with( + method="DELETE", + url="/volumes/1", + ) assert delete_success is True @@ -457,9 +527,13 @@ def test_attach( generic_action, ): request_mock.return_value = generic_action + action = volumes_client.attach(volume, server) + request_mock.assert_called_with( - url="/volumes/12/actions/attach", method="POST", json={"server": 1} + method="POST", + url="/volumes/12/actions/attach", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 @@ -475,8 +549,13 @@ def test_detach( generic_action, ): request_mock.return_value = generic_action + action = volumes_client.detach(volume) - request_mock.assert_called_with(url="/volumes/12/actions/detach", method="POST") + + request_mock.assert_called_with( + method="POST", + url="/volumes/12/actions/detach", + ) assert action.id == 1 assert action.progress == 0 @@ -491,9 +570,13 @@ def test_resize( generic_action, ): request_mock.return_value = generic_action + action = volumes_client.resize(volume, 50) + request_mock.assert_called_with( - url="/volumes/12/actions/resize", method="POST", json={"size": 50} + method="POST", + url="/volumes/12/actions/resize", + json={"size": 50}, ) assert action.id == 1 assert action.progress == 0 @@ -507,7 +590,10 @@ def test_actions_get_by_id( request_mock.return_value = {"action": response_get_actions["actions"][0]} action = volumes_client.actions.get_by_id(13) - request_mock.assert_called_with(url="/volumes/actions/13", method="GET") + request_mock.assert_called_with( + method="GET", + url="/volumes/actions/13", + ) assert isinstance(action, BoundAction) assert action._client == volumes_client._parent.actions @@ -521,11 +607,12 @@ def test_actions_get_list( response_get_actions, ): request_mock.return_value = response_get_actions + result = volumes_client.actions.get_list() request_mock.assert_called_with( - url="/volumes/actions", method="GET", + url="/volumes/actions", params={}, ) @@ -545,11 +632,12 @@ def test_actions_get_all( response_get_actions, ): request_mock.return_value = response_get_actions + actions = volumes_client.actions.get_all() request_mock.assert_called_with( - url="/volumes/actions", method="GET", + url="/volumes/actions", params={"page": 1, "per_page": 50}, ) From 8de41076bdf29bccd93ecc3eb6970f5f1fd86c18 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 15 Aug 2025 18:09:13 +0200 Subject: [PATCH 087/213] test: add global action fixtures (#544) Move the actions fixtures to all tests, to be able to reuse them in the future. --- tests/unit/actions/conftest.py | 79 ----------- tests/unit/actions/test_client.py | 159 ++++++++++------------- tests/unit/certificates/test_client.py | 8 +- tests/unit/conftest.py | 106 +++++++++++++-- tests/unit/floating_ips/test_client.py | 40 +++--- tests/unit/images/test_client.py | 16 +-- tests/unit/load_balancers/test_client.py | 16 +-- tests/unit/networks/test_client.py | 60 ++++----- tests/unit/primary_ips/test_client.py | 40 +++--- tests/unit/servers/test_client.py | 124 +++++++++--------- tests/unit/ssh_keys/test_client.py | 8 +- tests/unit/volumes/test_client.py | 44 +++---- 12 files changed, 339 insertions(+), 361 deletions(-) delete mode 100644 tests/unit/actions/conftest.py diff --git a/tests/unit/actions/conftest.py b/tests/unit/actions/conftest.py deleted file mode 100644 index dbc3cc9f..00000000 --- a/tests/unit/actions/conftest.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import pytest - - -@pytest.fixture() -def generic_action_list(): - return { - "actions": [ - { - "id": 1, - "command": "start_server", - "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"}, - }, - { - "id": 2, - "command": "stop_server", - "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"}, - }, - ] - } - - -@pytest.fixture() -def running_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "status": "running", - "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"}, - } - } - - -@pytest.fixture() -def successfully_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "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"}, - } - } - - -@pytest.fixture() -def failed_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "status": "error", - "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"}, - } - } diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 761452d0..389d06f7 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -6,7 +6,6 @@ from hcloud import Client from hcloud.actions import ( - Action, ActionFailedException, ActionsClient, ActionTimeoutException, @@ -14,68 +13,79 @@ ResourceActionsClient, ) +from ..conftest import assert_bound_action1, assert_bound_action2 + class TestBoundAction: @pytest.fixture() - def bound_running_action(self, client: Client): - return BoundAction( - client=client.actions, - data=dict(id=14, status=Action.STATUS_RUNNING), - ) + def bound_running_action(self, client: Client, action1_running): + return BoundAction(client=client.actions, data=action1_running) def test_wait_until_finished( self, request_mock: mock.MagicMock, bound_running_action, - running_action, - successfully_action, + action1_running, + action1_success, ): request_mock.side_effect = [ - running_action, - successfully_action, + {"action": action1_running}, + {"action": action1_success}, ] bound_running_action.wait_until_finished() request_mock.assert_called_with( method="GET", - url="/actions/2", + url="/actions/1", ) assert bound_running_action.status == "success" + assert bound_running_action.id == 1 + assert request_mock.call_count == 2 def test_wait_until_finished_with_error( self, request_mock: mock.MagicMock, bound_running_action, - running_action, - failed_action, + action1_running, + action1_error, ): - request_mock.side_effect = [running_action, failed_action] - with pytest.raises(ActionFailedException) as exception_info: + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_error}, + ] + + with pytest.raises(ActionFailedException) as exc: bound_running_action.wait_until_finished() assert bound_running_action.status == "error" - assert exception_info.value.action.id == 2 + assert bound_running_action.id == 1 + assert exc.value.action.id == 1 + + assert request_mock.call_count == 2 def test_wait_until_finished_max_retries( self, request_mock: mock.MagicMock, bound_running_action, - running_action, - successfully_action, + action1_running, + action1_success, ): request_mock.side_effect = [ - running_action, - running_action, - successfully_action, + {"action": action1_running}, + {"action": action1_running}, + {"action": action1_success}, ] - with pytest.raises(ActionTimeoutException) as exception_info: + + with pytest.raises(ActionTimeoutException) as exc: bound_running_action.wait_until_finished(max_retries=1) assert bound_running_action.status == "running" - assert exception_info.value.action.id == 2 + assert bound_running_action.id == 1 + assert exc.value.action.id == 1 + assert request_mock.call_count == 1 @@ -88,9 +98,9 @@ def test_get_by_id( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = actions_client.get_by_id(1) @@ -98,22 +108,24 @@ def test_get_by_id( method="GET", url="/resource/actions/1", ) - assert action._client == actions_client._parent.actions - assert action.id == 1 - assert action.command == "stop_server" + + assert_bound_action1(action, actions_client._parent.actions) @pytest.mark.parametrize( "params", - [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], + [ + {}, + {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) def test_get_list( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action_list, + action_list_response, params, ): - request_mock.return_value = generic_action_list + request_mock.return_value = action_list_response result = actions_client.get_list(**params) @@ -127,50 +139,30 @@ def test_get_list( actions = result.actions assert len(actions) == 2 - - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._parent.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._parent.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert_bound_action1(actions[0], actions_client._parent.actions) + assert_bound_action2(actions[1], actions_client._parent.actions) @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) def test_get_all( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action_list, + action_list_response, params, ): - request_mock.return_value = generic_action_list + request_mock.return_value = action_list_response actions = actions_client.get_all(**params) - params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with( method="GET", url="/resource/actions", - params=params, + params={**params, "page": 1, "per_page": 50}, ) assert len(actions) == 2 - - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._parent.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._parent.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert_bound_action1(actions[0], actions_client._parent.actions) + assert_bound_action2(actions[1], actions_client._parent.actions) class TestActionsClient: @@ -182,9 +174,9 @@ def test_get_by_id( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = actions_client.get_by_id(1) @@ -192,22 +184,23 @@ def test_get_by_id( method="GET", url="/actions/1", ) - assert action._client == actions_client._parent.actions - assert action.id == 1 - assert action.command == "stop_server" + assert_bound_action1(action, actions_client._parent.actions) @pytest.mark.parametrize( "params", - [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], + [ + {}, + {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) def test_get_list( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action_list, + action_list_response, params, ): - request_mock.return_value = generic_action_list + request_mock.return_value = action_list_response with pytest.deprecated_call(): result = actions_client.get_list(**params) @@ -222,48 +215,28 @@ def test_get_list( actions = result.actions assert len(actions) == 2 - - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._parent.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._parent.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert_bound_action1(actions[0], actions_client._parent.actions) + assert_bound_action2(actions[1], actions_client._parent.actions) @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) def test_get_all( self, request_mock: mock.MagicMock, actions_client: ActionsClient, - generic_action_list, + action_list_response, params, ): - request_mock.return_value = generic_action_list + request_mock.return_value = action_list_response with pytest.deprecated_call(): actions = actions_client.get_all(**params) - params.update({"page": 1, "per_page": 50}) - request_mock.assert_called_with( method="GET", url="/actions", - params=params, + params={**params, "page": 1, "per_page": 50}, ) assert len(actions) == 2 - - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._parent.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._parent.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert_bound_action1(actions[0], actions_client._parent.actions) + assert_bound_action2(actions[1], actions_client._parent.actions) diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 0c7c8c7f..26434074 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -118,9 +118,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_certificate, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_certificate.delete() @@ -362,9 +362,9 @@ def test_delete( request_mock: mock.MagicMock, certificates_client: CertificatesClient, certificate, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = certificates_client.delete(certificate) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index ce6adbe6..1e9a75b3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,6 +7,7 @@ import pytest from hcloud import Client +from hcloud.actions import ActionsClient, BoundAction @pytest.fixture(autouse=True, scope="session") @@ -32,17 +33,100 @@ def client(request_mock) -> Client: return c +def assert_bound_action1(o: BoundAction, client: ActionsClient): + assert o.id == 1 + assert o.command == "command" + assert o._client == client + + +def assert_bound_action2(o: BoundAction, client: ActionsClient): + assert o.id == 2 + assert o.command == "command" + assert o._client == client + + +@pytest.fixture() +def action1_running(): + return { + "id": 1, + "command": "command", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [{"id": 42, "type": "resource"}], + "error": None, + } + + +@pytest.fixture() +def action2_running(): + return { + "id": 2, + "command": "command", + "status": "running", + "progress": 20, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [{"id": 43, "type": "resource"}], + "error": None, + } + + +@pytest.fixture() +def action1_success(action1_running): + return { + **action1_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + } + + +@pytest.fixture() +def action2_success(action2_running): + return { + **action2_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + } + + +@pytest.fixture() +def action1_error(action1_running): + return { + **action1_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } + + +@pytest.fixture() +def action2_error(action2_running): + return { + **action2_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } + + +@pytest.fixture() +def action_response(action1_running): + return { + "action": action1_running, + } + + @pytest.fixture() -def generic_action(): +def action_list_response(action1_running, action2_running): return { - "action": { - "id": 1, - "command": "stop_server", - "status": "running", - "progress": 0, - "started": "2016-01-30T23:50+00:00", - "finished": None, - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } + "actions": [ + action1_running, + action2_running, + ], } diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index 1cc5bd1f..fc90419c 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -91,9 +91,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_floating_ip.delete() @@ -108,9 +108,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_floating_ip.change_protection(True) @@ -131,9 +131,9 @@ def test_assign( request_mock: mock.MagicMock, bound_floating_ip, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_floating_ip.assign(server) @@ -149,9 +149,9 @@ def test_unassign( self, request_mock: mock.MagicMock, bound_floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_floating_ip.unassign() @@ -166,9 +166,9 @@ def test_change_dns_ptr( self, request_mock: mock.MagicMock, bound_floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") @@ -448,9 +448,9 @@ def test_change_protection( request_mock: mock.MagicMock, floating_ips_client: FloatingIPsClient, floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = floating_ips_client.change_protection(floating_ip, True) @@ -471,9 +471,9 @@ def test_delete( request_mock: mock.MagicMock, floating_ips_client: FloatingIPsClient, floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = floating_ips_client.delete(floating_ip) @@ -500,9 +500,9 @@ def test_assign( floating_ips_client: FloatingIPsClient, server, floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = floating_ips_client.assign(floating_ip, server) @@ -523,9 +523,9 @@ def test_unassign( request_mock: mock.MagicMock, floating_ips_client: FloatingIPsClient, floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = floating_ips_client.unassign(floating_ip) @@ -545,9 +545,9 @@ def test_change_dns_ptr( request_mock: mock.MagicMock, floating_ips_client: FloatingIPsClient, floating_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = floating_ips_client.change_dns_ptr( floating_ip, "1.2.3.4", "server02.example.com" diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 384dce48..49fc76ac 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -133,9 +133,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_image, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_image.delete() @@ -150,9 +150,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_image, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_image.change_protection(True) @@ -398,9 +398,9 @@ def test_change_protection( request_mock: mock.MagicMock, images_client: ImagesClient, image, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = images_client.change_protection(image, True) @@ -421,9 +421,9 @@ def test_delete( request_mock: mock.MagicMock, images_client: ImagesClient, image, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = images_client.delete(image) diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index a41a2c3b..ff60b88c 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -114,9 +114,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_load_balancer, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_load_balancer.delete() @@ -464,9 +464,9 @@ def test_change_type( self, request_mock: mock.MagicMock, bound_load_balancer, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_load_balancer.change_type(LoadBalancerType(name="lb21")) @@ -644,9 +644,9 @@ def test_change_type_with_load_balancer_type_name( request_mock: mock.MagicMock, load_balancers_client: LoadBalancersClient, load_balancer, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = load_balancers_client.change_type( load_balancer, LoadBalancerType(name="lb11") @@ -670,9 +670,9 @@ def test_change_type_with_load_balancer_type_id( request_mock: mock.MagicMock, load_balancers_client: LoadBalancersClient, load_balancer, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = load_balancers_client.change_type( load_balancer, LoadBalancerType(id=1) diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index da2ee792..c2985c9f 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -96,9 +96,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_network.delete() @@ -113,9 +113,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_network.change_protection(True) @@ -132,9 +132,9 @@ def test_add_subnet( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response subnet = NetworkSubnet( type=NetworkSubnet.TYPE_CLOUD, @@ -160,9 +160,9 @@ def test_delete_subnet( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response subnet = NetworkSubnet(ip_range="10.0.1.0/24") action = bound_network.delete_subnet(subnet) @@ -180,9 +180,9 @@ def test_add_route( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.add_route(route) @@ -200,9 +200,9 @@ def test_delete_route( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") action = bound_network.delete_route(route) @@ -220,9 +220,9 @@ def test_change_ip( self, request_mock: mock.MagicMock, bound_network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_network.change_ip_range("10.0.0.0/12") @@ -619,9 +619,9 @@ def test_change_protection( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.change_protection(network, True) @@ -642,9 +642,9 @@ def test_delete( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = networks_client.delete(network) @@ -663,10 +663,10 @@ def test_add_subnet( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, network_subnet, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.add_subnet(network, network_subnet) @@ -691,10 +691,10 @@ def test_add_subnet_vswitch( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, network_vswitch_subnet, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.add_subnet(network, network_vswitch_subnet) @@ -720,10 +720,10 @@ def test_delete_subnet( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, network_subnet, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.delete_subnet(network, network_subnet) @@ -744,10 +744,10 @@ def test_add_route( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, network_route, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.add_route(network, network_route) @@ -768,10 +768,10 @@ def test_delete_route( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, network_route, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.delete_route(network, network_route) @@ -792,9 +792,9 @@ def test_change_ip_range( request_mock: mock.MagicMock, networks_client: NetworksClient, network, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = networks_client.change_ip_range(network, "10.0.0.0/12") diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index b7b3c07c..6bec0e76 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -63,9 +63,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_primary_ip.delete() @@ -80,9 +80,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_primary_ip.change_protection(True) @@ -99,9 +99,9 @@ def test_assign( self, request_mock: mock.MagicMock, bound_primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_primary_ip.assign(assignee_id=12, assignee_type="server") @@ -117,9 +117,9 @@ def test_unassign( self, request_mock: mock.MagicMock, bound_primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_primary_ip.unassign() @@ -134,9 +134,9 @@ def test_change_dns_ptr( self, request_mock: mock.MagicMock, bound_primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_primary_ip.change_dns_ptr("1.2.3.4", "server02.example.com") @@ -319,9 +319,9 @@ def test_change_protection( request_mock: mock.MagicMock, primary_ips_client: PrimaryIPsClient, primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = primary_ips_client.change_protection(primary_ip, True) @@ -342,9 +342,9 @@ def test_delete( request_mock: mock.MagicMock, primary_ips_client: PrimaryIPsClient, primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = primary_ips_client.delete(primary_ip) @@ -369,9 +369,9 @@ def test_assign( assignee_id, assignee_type, primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) @@ -391,9 +391,9 @@ def test_unassign( request_mock: mock.MagicMock, primary_ips_client: PrimaryIPsClient, primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = primary_ips_client.unassign(primary_ip) @@ -412,9 +412,9 @@ def test_change_dns_ptr( request_mock: mock.MagicMock, primary_ips_client: PrimaryIPsClient, primary_ip, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = primary_ips_client.change_dns_ptr( primary_ip, "1.2.3.4", "server02.example.com" diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 7b496c51..43088dfb 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -213,9 +213,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.delete() @@ -259,9 +259,9 @@ def test_power_off( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.power_off() @@ -277,9 +277,9 @@ def test_power_on( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.power_on() @@ -295,9 +295,9 @@ def test_reboot( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.reboot() @@ -313,9 +313,9 @@ def test_reset( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.reset() @@ -331,9 +331,9 @@ def test_shutdown( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.shutdown() @@ -368,9 +368,9 @@ def test_change_type( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) @@ -407,9 +407,9 @@ def test_disable_rescue( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.disable_rescue() @@ -445,9 +445,9 @@ def test_rebuild( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response response = bound_server.rebuild( Image(name="ubuntu-20.04"), @@ -468,9 +468,9 @@ def test_enable_backup( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.enable_backup() @@ -486,9 +486,9 @@ def test_disable_backup( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.disable_backup() @@ -504,9 +504,9 @@ def test_attach_iso( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) @@ -523,9 +523,9 @@ def test_detach_iso( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.detach_iso() @@ -541,9 +541,9 @@ def test_change_dns_ptr( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.change_dns_ptr("1.2.3.4", "example.com") @@ -560,9 +560,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_server.change_protection(True, True) @@ -1169,9 +1169,9 @@ def test_delete( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.delete(server) @@ -1191,9 +1191,9 @@ def test_power_off( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.power_off(server) @@ -1213,9 +1213,9 @@ def test_power_on( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.power_on(server) @@ -1235,9 +1235,9 @@ def test_reboot( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.reboot(server) @@ -1257,9 +1257,9 @@ def test_reset( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.reset(server) @@ -1279,9 +1279,9 @@ def test_shutdown( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.shutdown(server) @@ -1324,9 +1324,9 @@ def test_change_type_with_server_type_name( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.change_type( server, ServerType(name="cx11"), upgrade_disk=True @@ -1349,9 +1349,9 @@ def test_change_type_with_server_type_id( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.change_type(server, ServerType(id=1), upgrade_disk=True) @@ -1411,9 +1411,9 @@ def test_disable_rescue( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.disable_rescue(server) @@ -1463,9 +1463,9 @@ def test_rebuild( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response response = servers_client.rebuild( server, @@ -1491,9 +1491,9 @@ def test_enable_backup( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.enable_backup(server) @@ -1513,9 +1513,9 @@ def test_disable_backup( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.disable_backup(server) @@ -1535,9 +1535,9 @@ def test_attach_iso( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.attach_iso( server, Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1") @@ -1560,9 +1560,9 @@ def test_detach_iso( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.detach_iso(server) @@ -1582,9 +1582,9 @@ def test_change_dns_ptr( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.change_dns_ptr(server, "1.2.3.4", "example.com") @@ -1605,9 +1605,9 @@ def test_change_protection( request_mock: mock.MagicMock, servers_client: ServersClient, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = servers_client.change_protection(server, True, True) diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 24cf48f7..31e58e71 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -49,9 +49,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_ssh_key, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_ssh_key.delete() @@ -264,9 +264,9 @@ def test_delete( request_mock: mock.MagicMock, ssh_keys_client: SSHKeysClient, ssh_key, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = ssh_keys_client.delete(ssh_key) diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index fc0bffa2..e01ffe46 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -88,9 +88,9 @@ def test_delete( self, request_mock: mock.MagicMock, bound_volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = bound_volume.delete() @@ -105,9 +105,9 @@ def test_change_protection( self, request_mock: mock.MagicMock, bound_volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_volume.change_protection(True) @@ -128,9 +128,9 @@ def test_attach( request_mock: mock.MagicMock, bound_volume, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_volume.attach(server) @@ -150,9 +150,9 @@ def test_attach_with_automount( request_mock: mock.MagicMock, bound_volume, server, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_volume.attach(server, False) @@ -168,9 +168,9 @@ def test_detach( self, request_mock: mock.MagicMock, bound_volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_volume.detach() @@ -185,9 +185,9 @@ def test_resize( self, request_mock: mock.MagicMock, bound_volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = bound_volume.resize(50) @@ -472,9 +472,9 @@ def test_change_protection( request_mock: mock.MagicMock, volumes_client: VolumesClient, volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = volumes_client.change_protection(volume, True) @@ -495,9 +495,9 @@ def test_delete( request_mock: mock.MagicMock, volumes_client: VolumesClient, volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response delete_success = volumes_client.delete(volume) @@ -524,9 +524,9 @@ def test_attach( volumes_client: VolumesClient, server, volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = volumes_client.attach(volume, server) @@ -546,9 +546,9 @@ def test_detach( request_mock: mock.MagicMock, volumes_client: VolumesClient, volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = volumes_client.detach(volume) @@ -567,9 +567,9 @@ def test_resize( request_mock: mock.MagicMock, volumes_client: VolumesClient, volume, - generic_action, + action_response, ): - request_mock.return_value = generic_action + request_mock.return_value = action_response action = volumes_client.resize(volume, 50) From f933cc32a51586653e8a188554c1b94bf3f671fc Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 18 Aug 2025 10:54:32 +0200 Subject: [PATCH 088/213] test: generate actions tests for each resource that have actions (#545) - Reduce the amount of duplicate tests for the actions calls, - Ensure consistency across all resources, - Have few good tests, instead of many with few coverage. --- tests/unit/actions/test_client.py | 277 ++++++++++++++++++++--- tests/unit/certificates/test_client.py | 120 ---------- tests/unit/firewalls/test_client.py | 154 ------------- tests/unit/floating_ips/test_client.py | 117 ---------- tests/unit/images/test_client.py | 154 ------------- tests/unit/load_balancers/test_client.py | 122 ---------- tests/unit/networks/test_client.py | 118 ---------- tests/unit/primary_ips/test_client.py | 67 ------ tests/unit/servers/test_client.py | 164 -------------- tests/unit/volumes/test_client.py | 118 ---------- 10 files changed, 250 insertions(+), 1161 deletions(-) diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 389d06f7..e3c266b2 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import inspect from unittest import mock import pytest @@ -12,9 +13,48 @@ BoundAction, ResourceActionsClient, ) +from hcloud.certificates import BoundCertificate, CertificatesClient +from hcloud.core import BoundModelBase, ResourceClientBase +from hcloud.firewalls import BoundFirewall, FirewallsClient +from hcloud.floating_ips import BoundFloatingIP, FloatingIPsClient +from hcloud.images import BoundImage, ImagesClient +from hcloud.load_balancers import BoundLoadBalancer, LoadBalancersClient +from hcloud.networks import BoundNetwork, NetworksClient +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient +from hcloud.servers import BoundServer, ServersClient +from hcloud.volumes import BoundVolume, VolumesClient from ..conftest import assert_bound_action1, assert_bound_action2 +resources_with_actions: dict[str, tuple[ResourceClientBase, BoundModelBase]] = { + "certificates": (CertificatesClient, BoundCertificate), + "firewalls": (FirewallsClient, BoundFirewall), + "floating_ips": (FloatingIPsClient, BoundFloatingIP), + "images": (ImagesClient, BoundImage), + "load_balancers": (LoadBalancersClient, BoundLoadBalancer), + "networks": (NetworksClient, BoundNetwork), + "primary_ips": (PrimaryIPsClient, BoundPrimaryIP), + "servers": (ServersClient, BoundServer), + "volumes": (VolumesClient, BoundVolume), +} + + +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"), + ) + for name, member in members: + assert name in resources_with_actions + + resource_client_class, _ = resources_with_actions[name] + assert member.__class__ is resource_client_class + + assert len(members) == len(resources_with_actions) + class TestBoundAction: @pytest.fixture() @@ -90,48 +130,62 @@ def test_wait_until_finished_max_retries( class TestResourceActionsClient: + """ + //actions + //actions/ + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request) -> str: + return request.param + @pytest.fixture() - def actions_client(self, client: Client): - return ResourceActionsClient(client, resource="/resource") + def resource_client(self, client: Client, resource: str) -> ResourceActionsClient: + """ + Extract the resource actions client from the client. + """ + return getattr(client, resource).actions def test_get_by_id( self, request_mock: mock.MagicMock, - actions_client: ActionsClient, + resource_client: ResourceActionsClient, + resource: str, action_response, ): request_mock.return_value = action_response - action = actions_client.get_by_id(1) + action = resource_client.get_by_id(1) request_mock.assert_called_with( method="GET", - url="/resource/actions/1", + url=f"/{resource}/actions/1", ) - assert_bound_action1(action, actions_client._parent.actions) + assert_bound_action1(action, resource_client._parent.actions) @pytest.mark.parametrize( "params", [ {}, - {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, ], ) def test_get_list( self, request_mock: mock.MagicMock, - actions_client: ActionsClient, + resource_client: ResourceActionsClient, + resource: str, action_list_response, params, ): request_mock.return_value = action_list_response - result = actions_client.get_list(**params) + result = resource_client.get_list(**params) request_mock.assert_called_with( method="GET", - url="/resource/actions", + url=f"/{resource}/actions", params=params, ) @@ -139,36 +193,199 @@ def test_get_list( actions = result.actions assert len(actions) == 2 - assert_bound_action1(actions[0], actions_client._parent.actions) - assert_bound_action2(actions[1], actions_client._parent.actions) + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) def test_get_all( self, request_mock: mock.MagicMock, - actions_client: ActionsClient, + resource_client: ResourceActionsClient, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = resource_client.get_all(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) + + +class TestResourceObjectActionsClient: + """ + ///actions + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + if request.param == "primary_ips": + pytest.skip("not implemented yet") + return request.param + + @pytest.fixture() + def resource_client(self, client: Client, resource: str) -> ResourceClientBase: + return getattr(client, resource) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + resource_client: ResourceClientBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = resource_client.get_actions_list(mock.MagicMock(id=1), **params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, + ) + + assert result.meta is not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + resource_client: ResourceClientBase, + resource: str, action_list_response, params, ): request_mock.return_value = action_list_response - actions = actions_client.get_all(**params) + actions = resource_client.get_actions(mock.MagicMock(id=1), **params) request_mock.assert_called_with( method="GET", - url="/resource/actions", + url=f"/{resource}/1/actions", params={**params, "page": 1, "per_page": 50}, ) assert len(actions) == 2 - assert_bound_action1(actions[0], actions_client._parent.actions) - assert_bound_action2(actions[1], actions_client._parent.actions) + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) + + +class TestBoundModelActions: + """ + ///actions + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + if request.param == "primary_ips": + pytest.skip("not implemented yet") + return request.param + + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> ResourceClientBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = bound_model.get_actions_list(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, + ) + + assert result.meta is not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = bound_model.get_actions(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) class TestActionsClient: @pytest.fixture() - def actions_client(self, client: Client): - return ActionsClient(client) + def actions_client(self, client: Client) -> ActionsClient: + return client.actions def test_get_by_id( self, @@ -184,13 +401,13 @@ def test_get_by_id( method="GET", url="/actions/1", ) - assert_bound_action1(action, actions_client._parent.actions) + assert_bound_action1(action, actions_client) @pytest.mark.parametrize( "params", [ {}, - {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, ], ) def test_get_list( @@ -215,10 +432,16 @@ def test_get_list( actions = result.actions assert len(actions) == 2 - assert_bound_action1(actions[0], actions_client._parent.actions) - assert_bound_action2(actions[1], actions_client._parent.actions) + assert_bound_action1(actions[0], actions_client) + assert_bound_action2(actions[1], actions_client) - @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) def test_get_all( self, request_mock: mock.MagicMock, @@ -238,5 +461,5 @@ def test_get_all( ) assert len(actions) == 2 - assert_bound_action1(actions[0], actions_client._parent.actions) - assert_bound_action2(actions[1], actions_client._parent.actions) + assert_bound_action1(actions[0], actions_client) + assert_bound_action2(actions[1], actions_client) diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 26434074..cd1c8a60 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -5,7 +5,6 @@ import pytest from hcloud import Client -from hcloud.actions import BoundAction from hcloud.certificates import ( BoundCertificate, Certificate, @@ -19,59 +18,6 @@ class TestBoundCertificate: def bound_certificate(self, client: Client): return BoundCertificate(client.certificates, data=dict(id=14)) - @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - client: Client, - bound_certificate, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - result = bound_certificate.get_actions_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/certificates/14/actions", - params=params, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_certificate, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = bound_certificate.get_actions() - - params = {"page": 1, "per_page": 50} - - request_mock.assert_called_with( - method="GET", - url="/certificates/14/actions", - params=params, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - def test_bound_certificate_init(self, certificate_response): bound_certificate = BoundCertificate( client=mock.MagicMock(), data=certificate_response["certificate"] @@ -397,69 +343,3 @@ def test_retry_issuance( assert action.id == 14 assert action.command == "issue_certificate" - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - certificates_client: CertificatesClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = certificates_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/certificates/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == certificates_client._parent.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - certificates_client: CertificatesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = certificates_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/certificates/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - certificates_client: CertificatesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = certificates_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/certificates/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index 9c65a26b..78b66409 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -5,7 +5,6 @@ import pytest from hcloud import Client -from hcloud.actions import BoundAction from hcloud.firewalls import ( BoundFirewall, Firewall, @@ -72,63 +71,6 @@ def test_bound_firewall_init(self, firewall_response): ] assert firewall_out_rule.description == "allow http out" - @pytest.mark.parametrize( - "params", [{}, {"sort": ["created"], "page": 1, "per_page": 2}] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - client: Client, - bound_firewall, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - result = bound_firewall.get_actions_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/firewalls/1/actions", - params=params, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - - @pytest.mark.parametrize("params", [{}, {"sort": ["created"]}]) - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_firewall, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - actions = bound_firewall.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - request_mock.assert_called_with( - method="GET", - url="/firewalls/1/actions", - params=params, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - def test_update( self, request_mock: mock.MagicMock, @@ -379,36 +321,6 @@ def test_get_by_name( assert firewall.id == 38 assert firewall.name == "Corporate Intranet Protection" - @pytest.mark.parametrize( - "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - firewalls_client: FirewallsClient, - firewall, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = firewalls_client.get_actions_list(firewall) - - request_mock.assert_called_with( - method="GET", - url="/firewalls/1/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == firewalls_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - def test_create( self, request_mock: mock.MagicMock, @@ -599,69 +511,3 @@ def test_remove_from_resources( assert actions[0].id == 13 assert actions[0].progress == 100 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - firewalls_client: FirewallsClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = firewalls_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/firewalls/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == firewalls_client._parent.actions - assert action.id == 13 - assert action.command == "set_firewall_rules" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - firewalls_client: FirewallsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = firewalls_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/firewalls/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - firewalls_client: FirewallsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = firewalls_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/firewalls/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index fc90419c..acd2b175 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -5,7 +5,6 @@ import pytest from hcloud import Client -from hcloud.actions import BoundAction from hcloud.floating_ips import BoundFloatingIP, FloatingIP, FloatingIPsClient from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server @@ -42,29 +41,6 @@ def test_bound_floating_ip_init(self, floating_ip_response): assert bound_floating_ip.home_location.latitude == 50.47612 assert bound_floating_ip.home_location.longitude == 12.370071 - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_floating_ip, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = bound_floating_ip.get_actions(sort="id") - - request_mock.assert_called_with( - method="GET", - url="/floating_ips/14/actions", - params={"sort": "id", "page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - def test_update( self, request_mock: mock.MagicMock, @@ -387,33 +363,6 @@ def test_create_with_name( assert bound_floating_ip.name == "Web Frontend" assert action.id == 13 - @pytest.mark.parametrize( - "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions( - self, - request_mock: mock.MagicMock, - floating_ips_client: FloatingIPsClient, - floating_ip, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = floating_ips_client.get_actions(floating_ip) - - request_mock.assert_called_with( - method="GET", - url="/floating_ips/1/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == floating_ips_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) @@ -560,69 +509,3 @@ def test_change_dns_ptr( ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - floating_ips_client: FloatingIPsClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = floating_ips_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/floating_ips/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == floating_ips_client._parent.actions - assert action.id == 13 - assert action.command == "assign_floating_ip" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - floating_ips_client: FloatingIPsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = floating_ips_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/floating_ips/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - floating_ips_client: FloatingIPsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = floating_ips_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/floating_ips/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 49fc76ac..bc927983 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -7,7 +7,6 @@ import pytest from hcloud import Client -from hcloud.actions import BoundAction from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer @@ -47,63 +46,6 @@ def test_bound_image_init(self, image_response): assert bound_image.bound_to.id == 1 assert bound_image.bound_to.complete is False - @pytest.mark.parametrize( - "params", [{}, {"sort": ["status"], "page": 1, "per_page": 2}] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - client: Client, - bound_image, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - result = bound_image.get_actions_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/images/14/actions", - params=params, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - @pytest.mark.parametrize("params", [{}, {"sort": ["status"]}]) - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_image, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - actions = bound_image.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - request_mock.assert_called_with( - method="GET", - url="/images/14/actions", - params=params, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - def test_update( self, request_mock: mock.MagicMock, @@ -331,36 +273,6 @@ def test_get_by_name_and_architecture( assert image.name == "ubuntu-20.04" assert image.architecture == "x86" - @pytest.mark.parametrize( - "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - images_client: ImagesClient, - image, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = images_client.get_actions_list(image) - - request_mock.assert_called_with( - method="GET", - url="/images/1/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == images_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) @@ -433,69 +345,3 @@ def test_delete( ) assert delete_success is True - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - images_client: ImagesClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = images_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/images/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == images_client._parent.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - images_client: ImagesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = images_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/images/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - images_client: ImagesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = images_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/images/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index ff60b88c..94e5fb63 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -5,7 +5,6 @@ import pytest from hcloud import Client -from hcloud.actions import BoundAction from hcloud.load_balancer_types import LoadBalancerType from hcloud.load_balancers import ( BoundLoadBalancer, @@ -36,61 +35,6 @@ def test_bound_load_balancer_init(self, response_load_balancer): assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" - @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - client: Client, - bound_load_balancer, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - result = bound_load_balancer.get_actions_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/14/actions", - params=params, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - @pytest.mark.parametrize("params", [{}]) - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_load_balancer, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - actions = bound_load_balancer.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/14/actions", - params=params, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - def test_update( self, request_mock: mock.MagicMock, @@ -686,69 +630,3 @@ def test_change_type_with_load_balancer_type_id( assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = load_balancers_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == load_balancers_client._parent.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = load_balancers_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = load_balancers_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index c2985c9f..5b60983b 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -6,7 +6,6 @@ from dateutil.parser import isoparse from hcloud import Client -from hcloud.actions import BoundAction from hcloud.networks import ( BoundNetwork, Network, @@ -50,29 +49,6 @@ def test_bound_network_init(self, network_response): assert bound_network.routes[0].destination == "10.100.1.0/24" assert bound_network.routes[0].gateway == "10.0.1.1" - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_network, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = bound_network.get_actions(sort="id") - - request_mock.assert_called_with( - method="GET", - url="/networks/14/actions", - params={"page": 1, "per_page": 50, "sort": "id"}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" - def test_update( self, request_mock: mock.MagicMock, @@ -557,34 +533,6 @@ def test_create_with_route_and_subnet( }, ) - @pytest.mark.parametrize( - "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - networks_client: NetworksClient, - network, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = networks_client.get_actions_list(network, sort="id") - - request_mock.assert_called_with( - method="GET", - url="/networks/1/actions", - params={"sort": "id"}, - ) - - actions = result.actions - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == networks_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" - @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) @@ -806,69 +754,3 @@ def test_change_ip_range( assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - networks_client: NetworksClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = networks_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/networks/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == networks_client._parent.actions - assert action.id == 13 - assert action.command == "add_subnet" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - networks_client: NetworksClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = networks_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/networks/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - networks_client: NetworksClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = networks_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/networks/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 6bec0e76..ee3f5b4d 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.actions import BoundAction from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient @@ -427,69 +426,3 @@ def test_change_dns_ptr( ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = primary_ips_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/primary_ips/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == primary_ips_client._parent.actions - assert action.id == 13 - assert action.command == "assign_primary_ip" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = primary_ips_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/primary_ips/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_primary_ip" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = primary_ips_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/primary_ips/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_primary_ip" diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 43088dfb..423dc29b 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -122,74 +122,6 @@ def test_bound_server_init(self, response_full_server): assert bound_server.placement_group.name == "my Placement Group" assert bound_server.placement_group.complete is True - @pytest.mark.parametrize( - "params", - [ - { - "status": [Server.STATUS_RUNNING], - "sort": "status", - "page": 1, - "per_page": 10, - }, - {}, - ], - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - client: Client, - bound_server, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - result = bound_server.get_actions_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/servers/14/actions", - params=params, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - @pytest.mark.parametrize( - "params", [{"status": [Server.STATUS_RUNNING], "sort": "status"}, {}] - ) - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_server, - response_get_actions, - params, - ): - request_mock.return_value = response_get_actions - - actions = bound_server.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - request_mock.assert_called_with( - method="GET", - url="/servers/14/actions", - params=params, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - def test_update( self, request_mock: mock.MagicMock, @@ -1108,36 +1040,6 @@ def test_create_with_placement_group( assert next_actions[0].id == 13 - @pytest.mark.parametrize( - "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - servers_client: ServersClient, - server, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = servers_client.get_actions_list(server) - - request_mock.assert_called_with( - method="GET", - url="/servers/1/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == servers_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) @@ -1738,69 +1640,3 @@ def test_change_alias_ips( assert action.id == 1 assert action.progress == 0 assert action.command == "change_alias_ips" - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - servers_client: ServersClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = servers_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/servers/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == servers_client._parent.actions - assert action.id == 13 - assert action.command == "start_server" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - servers_client: ServersClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = servers_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/servers/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - servers_client: ServersClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = servers_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/servers/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index e01ffe46..9a1d0b20 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -6,7 +6,6 @@ from dateutil.parser import isoparse from hcloud import Client -from hcloud.actions import BoundAction from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server from hcloud.volumes import BoundVolume, Volume, VolumesClient @@ -42,29 +41,6 @@ def test_bound_volume_init(self, volume_response): assert bound_volume.location.latitude == 50.47612 assert bound_volume.location.longitude == 12.370071 - def test_get_actions( - self, - request_mock: mock.MagicMock, - client: Client, - bound_volume, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = bound_volume.get_actions(sort="id") - - request_mock.assert_called_with( - method="GET", - url="/volumes/14/actions", - params={"page": 1, "per_page": 50, "sort": "id"}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - def test_update( self, request_mock: mock.MagicMock, @@ -413,34 +389,6 @@ def test_create_wrong_location_server_combination( request_mock.assert_not_called() - @pytest.mark.parametrize( - "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list( - self, - request_mock: mock.MagicMock, - volumes_client: VolumesClient, - volume, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = volumes_client.get_actions_list(volume, sort="id") - - request_mock.assert_called_with( - method="GET", - url="/volumes/1/actions", - params={"sort": "id"}, - ) - - actions = result.actions - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == volumes_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) @@ -580,69 +528,3 @@ def test_resize( ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id( - self, - request_mock: mock.MagicMock, - volumes_client: VolumesClient, - response_get_actions, - ): - request_mock.return_value = {"action": response_get_actions["actions"][0]} - action = volumes_client.actions.get_by_id(13) - - request_mock.assert_called_with( - method="GET", - url="/volumes/actions/13", - ) - - assert isinstance(action, BoundAction) - assert action._client == volumes_client._parent.actions - assert action.id == 13 - assert action.command == "attach_volume" - - def test_actions_get_list( - self, - request_mock: mock.MagicMock, - volumes_client: VolumesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - result = volumes_client.actions.get_list() - - request_mock.assert_called_with( - method="GET", - url="/volumes/actions", - params={}, - ) - - actions = result.actions - assert result.meta is not None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - - def test_actions_get_all( - self, - request_mock: mock.MagicMock, - volumes_client: VolumesClient, - response_get_actions, - ): - request_mock.return_value = response_get_actions - - actions = volumes_client.actions.get_all() - - request_mock.assert_called_with( - method="GET", - url="/volumes/actions", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._parent.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" From 33da63f44879809688d0c7b8f57d8ac253e4006b Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 18 Aug 2025 11:35:19 +0200 Subject: [PATCH 089/213] refactor: add resource clients base_url (#542) This property will become powerful with the addition of the Resource Actions Client Mixin. --- hcloud/certificates/client.py | 21 +++++---- hcloud/core/client.py | 3 +- hcloud/datacenters/client.py | 5 ++- hcloud/firewalls/client.py | 21 ++++----- hcloud/floating_ips/client.py | 25 +++++------ hcloud/images/client.py | 15 ++++--- hcloud/isos/client.py | 5 ++- hcloud/load_balancer_types/client.py | 7 ++- hcloud/load_balancers/client.py | 45 ++++++++++--------- hcloud/locations/client.py | 5 ++- hcloud/networks/client.py | 27 ++++++------ hcloud/placement_groups/client.py | 15 +++---- hcloud/primary_ips/client.py | 21 ++++----- hcloud/server_types/client.py | 7 ++- hcloud/servers/client.py | 65 +++++++++++++++------------- hcloud/ssh_keys/client.py | 11 ++--- hcloud/volumes/client.py | 23 +++++----- 17 files changed, 163 insertions(+), 158 deletions(-) diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 72856ba8..9b75d9f3 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -104,6 +104,7 @@ class CertificatesPageResult(NamedTuple): class CertificatesClient(ResourceClientBase): + _base_url = "/certificates" actions: ResourceActionsClient """Certificates scoped actions client @@ -113,7 +114,7 @@ class CertificatesClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/certificates") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundCertificate: """Get a specific certificate by its ID. @@ -121,7 +122,7 @@ def get_by_id(self, id: int) -> BoundCertificate: :param id: int :return: :class:`BoundCertificate ` """ - response = self._client.request(url=f"/certificates/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundCertificate(self, response["certificate"]) def get_list( @@ -156,9 +157,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/certificates", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) certificates = [ BoundCertificate(self, certificate_data) @@ -218,7 +217,7 @@ def create( } if labels is not None: data["labels"] = labels - response = self._client.request(url="/certificates", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundCertificate(self, response["certificate"]) def create_managed( @@ -244,7 +243,7 @@ def create_managed( } if labels is not None: data["labels"] = labels - response = self._client.request(url="/certificates", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return CreateManagedCertificateResponse( certificate=BoundCertificate(self, response["certificate"]), action=BoundAction(self._parent.actions, response["action"]), @@ -271,7 +270,7 @@ def update( if labels is not None: data["labels"] = labels response = self._client.request( - url=f"/certificates/{certificate.id}", + url=f"{self._base_url}/{certificate.id}", method="PUT", json=data, ) @@ -284,7 +283,7 @@ def delete(self, certificate: Certificate | BoundCertificate) -> bool: :return: True """ self._client.request( - url=f"/certificates/{certificate.id}", + url=f"{self._base_url}/{certificate.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -322,7 +321,7 @@ def get_actions_list( params["per_page"] = per_page response = self._client.request( - url=f"/certificates/{certificate.id}/actions", + url=f"{self._base_url}/{certificate.id}/actions", method="GET", params=params, ) @@ -364,7 +363,7 @@ def retry_issuance( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/certificates/{certificate.id}/actions/retry", + url=f"{self._base_url}/{certificate.id}/actions/retry", method="POST", ) return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index ab91611e..07dfaf48 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Callable, ClassVar if TYPE_CHECKING: from .._client import Client, ClientBase @@ -9,6 +9,7 @@ class ResourceClientBase: + _base_url: ClassVar[str] _parent: Client _client: ClientBase diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 0d793cf3..880e1c24 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -53,6 +53,7 @@ class DatacentersPageResult(NamedTuple): class DatacentersClient(ResourceClientBase): + _base_url = "/datacenters" def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. @@ -60,7 +61,7 @@ def get_by_id(self, id: int) -> BoundDatacenter: :param id: int :return: :class:`BoundDatacenter ` """ - response = self._client.request(url=f"/datacenters/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundDatacenter(self, response["datacenter"]) def get_list( @@ -89,7 +90,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/datacenters", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) datacenters = [ BoundDatacenter(self, datacenter_data) diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 94b4cbd6..84535184 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -184,6 +184,7 @@ class FirewallsPageResult(NamedTuple): class FirewallsClient(ResourceClientBase): + _base_url = "/firewalls" actions: ResourceActionsClient """Firewalls scoped actions client @@ -193,7 +194,7 @@ class FirewallsClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/firewalls") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, @@ -226,7 +227,7 @@ def get_actions_list( if per_page is not None: params["per_page"] = per_page response = self._client.request( - url=f"/firewalls/{firewall.id}/actions", + url=f"{self._base_url}/{firewall.id}/actions", method="GET", params=params, ) @@ -265,7 +266,7 @@ def get_by_id(self, id: int) -> BoundFirewall: :param id: int :return: :class:`BoundFirewall ` """ - response = self._client.request(url=f"/firewalls/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFirewall(self, response["firewall"]) def get_list( @@ -302,7 +303,7 @@ def get_list( params["name"] = name if sort is not None: params["sort"] = sort - response = self._client.request(url="/firewalls", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) firewalls = [ BoundFirewall(self, firewall_data) for firewall_data in response["firewalls"] @@ -372,7 +373,7 @@ def create( data.update({"apply_to": []}) for resource in resources: data["apply_to"].append(resource.to_payload()) - response = self._client.request(url="/firewalls", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") actions = [] if response.get("actions") is not None: @@ -408,7 +409,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/firewalls/{firewall.id}", + url=f"{self._base_url}/{firewall.id}", method="PUT", json=data, ) @@ -421,7 +422,7 @@ def delete(self, firewall: Firewall | BoundFirewall) -> bool: :return: boolean """ self._client.request( - url=f"/firewalls/{firewall.id}", + url=f"{self._base_url}/{firewall.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -442,7 +443,7 @@ def set_rules( for rule in rules: data["rules"].append(rule.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/set_rules", + url=f"{self._base_url}/{firewall.id}/actions/set_rules", method="POST", json=data, ) @@ -466,7 +467,7 @@ def apply_to_resources( for resource in resources: data["apply_to"].append(resource.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/apply_to_resources", + url=f"{self._base_url}/{firewall.id}/actions/apply_to_resources", method="POST", json=data, ) @@ -490,7 +491,7 @@ def remove_from_resources( for resource in resources: data["remove_from"].append(resource.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/remove_from_resources", + url=f"{self._base_url}/{firewall.id}/actions/remove_from_resources", method="POST", json=data, ) diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index c1ce8403..cc876803 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -140,6 +140,7 @@ class FloatingIPsPageResult(NamedTuple): class FloatingIPsClient(ResourceClientBase): + _base_url = "/floating_ips" actions: ResourceActionsClient """Floating IPs scoped actions client @@ -149,7 +150,7 @@ class FloatingIPsClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/floating_ips") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, @@ -182,7 +183,7 @@ def get_actions_list( if per_page is not None: params["per_page"] = per_page response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions", + url=f"{self._base_url}/{floating_ip.id}/actions", method="GET", params=params, ) @@ -221,7 +222,7 @@ def get_by_id(self, id: int) -> BoundFloatingIP: :param id: int :return: :class:`BoundFloatingIP ` """ - response = self._client.request(url=f"/floating_ips/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFloatingIP(self, response["floating_ip"]) def get_list( @@ -254,9 +255,7 @@ def get_list( if name is not None: params["name"] = name - response = self._client.request( - url="/floating_ips", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) floating_ips = [ BoundFloatingIP(self, floating_ip_data) for floating_ip_data in response["floating_ips"] @@ -324,7 +323,7 @@ def create( if name is not None: data["name"] = name - response = self._client.request(url="/floating_ips", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") action = None if response.get("action") is not None: @@ -362,7 +361,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/floating_ips/{floating_ip.id}", + url=f"{self._base_url}/{floating_ip.id}", method="PUT", json=data, ) @@ -375,7 +374,7 @@ def delete(self, floating_ip: FloatingIP | BoundFloatingIP) -> bool: :return: boolean """ self._client.request( - url=f"/floating_ips/{floating_ip.id}", + url=f"{self._base_url}/{floating_ip.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -398,7 +397,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/change_protection", + url=f"{self._base_url}/{floating_ip.id}/actions/change_protection", method="POST", json=data, ) @@ -417,7 +416,7 @@ def assign( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/assign", + url=f"{self._base_url}/{floating_ip.id}/actions/assign", method="POST", json={"server": server.id}, ) @@ -430,7 +429,7 @@ def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/unassign", + url=f"{self._base_url}/{floating_ip.id}/actions/unassign", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -451,7 +450,7 @@ def change_dns_ptr( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{floating_ip.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) diff --git a/hcloud/images/client.py b/hcloud/images/client.py index ea4e66dd..5d993336 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -113,6 +113,7 @@ class ImagesPageResult(NamedTuple): class ImagesClient(ResourceClientBase): + _base_url = "/images" actions: ResourceActionsClient """Images scoped actions client @@ -122,7 +123,7 @@ class ImagesClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/images") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, @@ -155,7 +156,7 @@ def get_actions_list( if per_page is not None: params["per_page"] = per_page response = self._client.request( - url=f"/images/{image.id}/actions", + url=f"{self._base_url}/{image.id}/actions", method="GET", params=params, ) @@ -193,7 +194,7 @@ def get_by_id(self, id: int) -> BoundImage: :param id: int :return: :class:`BoundImage bool: :param :class:`BoundImage ` or :class:`Image ` :return: bool """ - self._client.request(url=f"/images/{image.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{image.id}", method="DELETE") # Return allays true, because the API does not return an action for it. When an error occurs a APIException will be raised return True @@ -401,7 +402,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/images/{image.id}/actions/change_protection", + url=f"{self._base_url}/{image.id}/actions/change_protection", method="POST", json=data, ) diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index 25c20c34..f3e86896 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -18,6 +18,7 @@ class IsosPageResult(NamedTuple): class IsosClient(ResourceClientBase): + _base_url = "/isos" def get_by_id(self, id: int) -> BoundIso: """Get a specific ISO by its id @@ -25,7 +26,7 @@ def get_by_id(self, id: int) -> BoundIso: :param id: int :return: :class:`BoundIso ` """ - response = self._client.request(url=f"/isos/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundIso(self, response["iso"]) def get_list( @@ -63,7 +64,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/isos", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) isos = [BoundIso(self, iso_data) for iso_data in response["isos"]] return IsosPageResult(isos, Meta.parse_meta(response)) diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index a237cb11..12d0f03e 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -18,6 +18,7 @@ class LoadBalancerTypesPageResult(NamedTuple): class LoadBalancerTypesClient(ResourceClientBase): + _base_url = "/load_balancer_types" def get_by_id(self, id: int) -> BoundLoadBalancerType: """Returns a specific Load Balancer Type. @@ -26,7 +27,7 @@ def get_by_id(self, id: int) -> BoundLoadBalancerType: :return: :class:`BoundLoadBalancerType ` """ response = self._client.request( - url=f"/load_balancer_types/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancerType(self, response["load_balancer_type"]) @@ -55,9 +56,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/load_balancer_types", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) load_balancer_types = [ BoundLoadBalancerType(self, load_balancer_type_data) for load_balancer_type_data in response["load_balancer_types"] diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 7e42a325..06625f27 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -370,6 +370,7 @@ class LoadBalancersPageResult(NamedTuple): class LoadBalancersClient(ResourceClientBase): + _base_url = "/load_balancers" actions: ResourceActionsClient """Load Balancers scoped actions client @@ -379,7 +380,7 @@ class LoadBalancersClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/load_balancers") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundLoadBalancer: """Get a specific Load Balancer @@ -388,7 +389,7 @@ def get_by_id(self, id: int) -> BoundLoadBalancer: :return: :class:`BoundLoadBalancer ` """ response = self._client.request( - url=f"/load_balancers/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancer(self, response["load_balancer"]) @@ -422,9 +423,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/load_balancers", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) load_balancers = [ BoundLoadBalancer(self, load_balancer_data) @@ -514,7 +513,7 @@ def create( if location is not None: data["location"] = location.id_or_name - response = self._client.request(url="/load_balancers", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return CreateLoadBalancerResponse( load_balancer=BoundLoadBalancer(self, response["load_balancer"]), @@ -542,7 +541,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}", + url=f"{self._base_url}/{load_balancer.id}", method="PUT", json=data, ) @@ -555,7 +554,7 @@ def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: :return: boolean """ self._client.request( - url=f"/load_balancers/{load_balancer.id}", + url=f"{self._base_url}/{load_balancer.id}", method="DELETE", ) return True @@ -592,7 +591,7 @@ def get_metrics( params["step"] = step response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/metrics", + url=f"{self._base_url}/{load_balancer.id}/metrics", method="GET", params=params, ) @@ -632,7 +631,7 @@ def get_actions_list( params["per_page"] = per_page response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions", + url=f"{self._base_url}/{load_balancer.id}/actions", method="GET", params=params, ) @@ -679,7 +678,7 @@ def add_service( data: dict[str, Any] = service.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/add_service", + url=f"{self._base_url}/{load_balancer.id}/actions/add_service", method="POST", json=data, ) @@ -699,7 +698,7 @@ def update_service( """ data: dict[str, Any] = service.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/update_service", + url=f"{self._base_url}/{load_balancer.id}/actions/update_service", method="POST", json=data, ) @@ -720,7 +719,7 @@ def delete_service( data: dict[str, Any] = {"listen_port": service.listen_port} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/delete_service", + url=f"{self._base_url}/{load_balancer.id}/actions/delete_service", method="POST", json=data, ) @@ -741,7 +740,7 @@ def add_target( data: dict[str, Any] = target.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/add_target", + url=f"{self._base_url}/{load_balancer.id}/actions/add_target", method="POST", json=data, ) @@ -764,7 +763,7 @@ def remove_target( data.pop("use_private_ip", None) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/remove_target", + url=f"{self._base_url}/{load_balancer.id}/actions/remove_target", method="POST", json=data, ) @@ -785,7 +784,7 @@ def change_algorithm( data: dict[str, Any] = {"type": algorithm.type} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_algorithm", + url=f"{self._base_url}/{load_balancer.id}/actions/change_algorithm", method="POST", json=data, ) @@ -807,7 +806,7 @@ def change_dns_ptr( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{load_balancer.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) @@ -830,7 +829,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_protection", + url=f"{self._base_url}/{load_balancer.id}/actions/change_protection", method="POST", json=data, ) @@ -855,7 +854,7 @@ def attach_to_network( data.update({"ip": ip}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/attach_to_network", + url=f"{self._base_url}/{load_balancer.id}/actions/attach_to_network", method="POST", json=data, ) @@ -874,7 +873,7 @@ def detach_from_network( """ data: dict[str, Any] = {"network": network.id} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/detach_from_network", + url=f"{self._base_url}/{load_balancer.id}/actions/detach_from_network", method="POST", json=data, ) @@ -892,7 +891,7 @@ def enable_public_interface( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/enable_public_interface", + url=f"{self._base_url}/{load_balancer.id}/actions/enable_public_interface", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -909,7 +908,7 @@ def disable_public_interface( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/disable_public_interface", + url=f"{self._base_url}/{load_balancer.id}/actions/disable_public_interface", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -928,7 +927,7 @@ def change_type( """ data: dict[str, Any] = {"load_balancer_type": load_balancer_type.id_or_name} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_type", + url=f"{self._base_url}/{load_balancer.id}/actions/change_type", method="POST", json=data, ) diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 11d83ca6..154ba843 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -18,6 +18,7 @@ class LocationsPageResult(NamedTuple): class LocationsClient(ResourceClientBase): + _base_url = "/locations" def get_by_id(self, id: int) -> BoundLocation: """Get a specific location by its ID. @@ -25,7 +26,7 @@ def get_by_id(self, id: int) -> BoundLocation: :param id: int :return: :class:`BoundLocation ` """ - response = self._client.request(url=f"/locations/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundLocation(self, response["location"]) def get_list( @@ -52,7 +53,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/locations", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) locations = [ BoundLocation(self, location_data) for location_data in response["locations"] diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 23059b89..5ac1b9d0 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -167,6 +167,7 @@ class NetworksPageResult(NamedTuple): class NetworksClient(ResourceClientBase): + _base_url = "/networks" actions: ResourceActionsClient """Networks scoped actions client @@ -176,7 +177,7 @@ class NetworksClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/networks") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundNetwork: """Get a specific network @@ -184,7 +185,7 @@ def get_by_id(self, id: int) -> BoundNetwork: :param id: int :return: :class:`BoundNetwork ` """ - response = self._client.request(url=f"/networks/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundNetwork(self, response["network"]) def get_list( @@ -216,7 +217,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/networks", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) networks = [ BoundNetwork(self, network_data) for network_data in response["networks"] @@ -300,7 +301,7 @@ def create( if labels is not None: data["labels"] = labels - response = self._client.request(url="/networks", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundNetwork(self, response["network"]) @@ -334,7 +335,7 @@ def update( data.update({"labels": labels}) response = self._client.request( - url=f"/networks/{network.id}", + url=f"{self._base_url}/{network.id}", method="PUT", json=data, ) @@ -346,7 +347,7 @@ def delete(self, network: Network | BoundNetwork) -> bool: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: boolean """ - self._client.request(url=f"/networks/{network.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") return True def get_actions_list( @@ -381,7 +382,7 @@ def get_actions_list( params["per_page"] = per_page response = self._client.request( - url=f"/networks/{network.id}/actions", + url=f"{self._base_url}/{network.id}/actions", method="GET", params=params, ) @@ -435,7 +436,7 @@ def add_subnet( data["vswitch_id"] = subnet.vswitch_id response = self._client.request( - url=f"/networks/{network.id}/actions/add_subnet", + url=f"{self._base_url}/{network.id}/actions/add_subnet", method="POST", json=data, ) @@ -456,7 +457,7 @@ def delete_subnet( data: dict[str, Any] = {"ip_range": subnet.ip_range} response = self._client.request( - url=f"/networks/{network.id}/actions/delete_subnet", + url=f"{self._base_url}/{network.id}/actions/delete_subnet", method="POST", json=data, ) @@ -480,7 +481,7 @@ def add_route( } response = self._client.request( - url=f"/networks/{network.id}/actions/add_route", + url=f"{self._base_url}/{network.id}/actions/add_route", method="POST", json=data, ) @@ -504,7 +505,7 @@ def delete_route( } response = self._client.request( - url=f"/networks/{network.id}/actions/delete_route", + url=f"{self._base_url}/{network.id}/actions/delete_route", method="POST", json=data, ) @@ -525,7 +526,7 @@ def change_ip_range( data: dict[str, Any] = {"ip_range": ip_range} response = self._client.request( - url=f"/networks/{network.id}/actions/change_ip_range", + url=f"{self._base_url}/{network.id}/actions/change_ip_range", method="POST", json=data, ) @@ -548,7 +549,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/networks/{network.id}/actions/change_protection", + url=f"{self._base_url}/{network.id}/actions/change_protection", method="POST", json=data, ) diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index 48894326..dbae26df 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -41,6 +41,7 @@ class PlacementGroupsPageResult(NamedTuple): class PlacementGroupsClient(ResourceClientBase): + _base_url = "/placement_groups" def get_by_id(self, id: int) -> BoundPlacementGroup: """Returns a specific Placement Group object @@ -49,7 +50,7 @@ def get_by_id(self, id: int) -> BoundPlacementGroup: :return: :class:`BoundPlacementGroup ` """ response = self._client.request( - url=f"/placement_groups/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundPlacementGroup(self, response["placement_group"]) @@ -92,9 +93,7 @@ def get_list( params["sort"] = sort if type is not None: params["type"] = type - response = self._client.request( - url="/placement_groups", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) placement_groups = [ BoundPlacementGroup(self, placement_group_data) for placement_group_data in response["placement_groups"] @@ -154,9 +153,7 @@ def create( data: dict[str, Any] = {"name": name, "type": type} if labels is not None: data["labels"] = labels - response = self._client.request( - url="/placement_groups", json=data, method="POST" - ) + response = self._client.request(url=self._base_url, json=data, method="POST") action = None if response.get("action") is not None: @@ -191,7 +188,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/placement_groups/{placement_group.id}", + url=f"{self._base_url}/{placement_group.id}", method="PUT", json=data, ) @@ -204,7 +201,7 @@ def delete(self, placement_group: PlacementGroup | BoundPlacementGroup) -> bool: :return: boolean """ self._client.request( - url=f"/placement_groups/{placement_group.id}", + url=f"{self._base_url}/{placement_group.id}", method="DELETE", ) return True diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 7bf4f4cc..497e8a5b 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -98,6 +98,7 @@ class PrimaryIPsPageResult(NamedTuple): class PrimaryIPsClient(ResourceClientBase): + _base_url = "/primary_ips" actions: ResourceActionsClient """Primary IPs scoped actions client @@ -107,7 +108,7 @@ class PrimaryIPsClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/primary_ips") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundPrimaryIP: """Returns a specific Primary IP object. @@ -115,7 +116,7 @@ def get_by_id(self, id: int) -> BoundPrimaryIP: :param id: int :return: :class:`BoundPrimaryIP ` """ - response = self._client.request(url=f"/primary_ips/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundPrimaryIP(self, response["primary_ip"]) def get_list( @@ -153,7 +154,7 @@ def get_list( if ip is not None: params["ip"] = ip - response = self._client.request(url="/primary_ips", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) primary_ips = [ BoundPrimaryIP(self, primary_ip_data) for primary_ip_data in response["primary_ips"] @@ -220,7 +221,7 @@ def create( if labels is not None: data["labels"] = labels - response = self._client.request(url="/primary_ips", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") action = None if response.get("action") is not None: @@ -258,7 +259,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/primary_ips/{primary_ip.id}", + url=f"{self._base_url}/{primary_ip.id}", method="PUT", json=data, ) @@ -271,7 +272,7 @@ def delete(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> bool: :return: boolean """ self._client.request( - url=f"/primary_ips/{primary_ip.id}", + url=f"{self._base_url}/{primary_ip.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -294,7 +295,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/change_protection", + url=f"{self._base_url}/{primary_ip.id}/actions/change_protection", method="POST", json=data, ) @@ -316,7 +317,7 @@ def assign( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/assign", + url=f"{self._base_url}/{primary_ip.id}/actions/assign", method="POST", json={"assignee_id": assignee_id, "assignee_type": assignee_type}, ) @@ -329,7 +330,7 @@ def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/unassign", + url=f"{self._base_url}/{primary_ip.id}/actions/unassign", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -350,7 +351,7 @@ def change_dns_ptr( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{primary_ip.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 3f9df22c..085b66f8 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -18,6 +18,7 @@ class ServerTypesPageResult(NamedTuple): class ServerTypesClient(ResourceClientBase): + _base_url = "/server_types" def get_by_id(self, id: int) -> BoundServerType: """Returns a specific Server Type. @@ -25,7 +26,7 @@ def get_by_id(self, id: int) -> BoundServerType: :param id: int :return: :class:`BoundServerType ` """ - response = self._client.request(url=f"/server_types/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServerType(self, response["server_type"]) def get_list( @@ -52,9 +53,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/server_types", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) server_types = [ BoundServerType(self, server_type_data) for server_type_data in response["server_types"] diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index b4f7e050..55347d5e 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -483,6 +483,7 @@ class ServersPageResult(NamedTuple): class ServersClient(ResourceClientBase): + _base_url = "/servers" actions: ResourceActionsClient """Servers scoped actions client @@ -492,7 +493,7 @@ class ServersClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/servers") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundServer: """Get a specific server @@ -500,7 +501,7 @@ def get_by_id(self, id: int) -> BoundServer: :param id: int :return: :class:`BoundServer ` """ - response = self._client.request(url=f"/servers/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServer(self, response["server"]) def get_list( @@ -537,7 +538,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/servers", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) ass_servers = [ BoundServer(self, server_data) for server_data in response["servers"] @@ -664,7 +665,7 @@ def create( data_public_net["ipv6"] = public_net.ipv6.id data["public_net"] = data_public_net - response = self._client.request(url="/servers", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) result = CreateServerResponse( server=BoundServer(self, response["server"]), @@ -709,7 +710,7 @@ def get_actions_list( params["per_page"] = per_page response = self._client.request( - url=f"/servers/{server.id}/actions", + url=f"{self._base_url}/{server.id}/actions", method="GET", params=params, ) @@ -762,7 +763,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/servers/{server.id}", + url=f"{self._base_url}/{server.id}", method="PUT", json=data, ) @@ -800,7 +801,7 @@ def get_metrics( params["step"] = step response = self._client.request( - url=f"/servers/{server.id}/metrics", + url=f"{self._base_url}/{server.id}/metrics", method="GET", params=params, ) @@ -814,7 +815,9 @@ def delete(self, server: Server | BoundServer) -> BoundAction: :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ - response = self._client.request(url=f"/servers/{server.id}", method="DELETE") + response = self._client.request( + url=f"{self._base_url}/{server.id}", method="DELETE" + ) return BoundAction(self._parent.actions, response["action"]) def power_off(self, server: Server | BoundServer) -> BoundAction: @@ -824,7 +827,7 @@ def power_off(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/poweroff", + url=f"{self._base_url}/{server.id}/actions/poweroff", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -836,7 +839,7 @@ def power_on(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/poweron", + url=f"{self._base_url}/{server.id}/actions/poweron", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -848,7 +851,7 @@ def reboot(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reboot", + url=f"{self._base_url}/{server.id}/actions/reboot", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -860,7 +863,7 @@ def reset(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reset", + url=f"{self._base_url}/{server.id}/actions/reset", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -872,7 +875,7 @@ def shutdown(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/shutdown", + url=f"{self._base_url}/{server.id}/actions/shutdown", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -884,7 +887,7 @@ def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: :return: :class:`ResetPasswordResponse ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reset_password", + url=f"{self._base_url}/{server.id}/actions/reset_password", method="POST", ) return ResetPasswordResponse( @@ -912,7 +915,7 @@ def change_type( "upgrade_disk": upgrade_disk, } response = self._client.request( - url=f"/servers/{server.id}/actions/change_type", + url=f"{self._base_url}/{server.id}/actions/change_type", method="POST", json=data, ) @@ -939,7 +942,7 @@ def enable_rescue( data.update({"ssh_keys": ssh_keys}) response = self._client.request( - url=f"/servers/{server.id}/actions/enable_rescue", + url=f"{self._base_url}/{server.id}/actions/enable_rescue", method="POST", json=data, ) @@ -955,7 +958,7 @@ def disable_rescue(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/disable_rescue", + url=f"{self._base_url}/{server.id}/actions/disable_rescue", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -990,7 +993,7 @@ def create_image( data.update({"labels": labels}) response = self._client.request( - url=f"/servers/{server.id}/actions/create_image", + url=f"{self._base_url}/{server.id}/actions/create_image", method="POST", json=data, ) @@ -1013,7 +1016,7 @@ def rebuild( """ data: dict[str, Any] = {"image": image.id_or_name} response = self._client.request( - url=f"/servers/{server.id}/actions/rebuild", + url=f"{self._base_url}/{server.id}/actions/rebuild", method="POST", json=data, ) @@ -1030,7 +1033,7 @@ def enable_backup(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/enable_backup", + url=f"{self._base_url}/{server.id}/actions/enable_backup", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -1042,7 +1045,7 @@ def disable_backup(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/disable_backup", + url=f"{self._base_url}/{server.id}/actions/disable_backup", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -1060,7 +1063,7 @@ def attach_iso( """ data: dict[str, Any] = {"iso": iso.id_or_name} response = self._client.request( - url=f"/servers/{server.id}/actions/attach_iso", + url=f"{self._base_url}/{server.id}/actions/attach_iso", method="POST", json=data, ) @@ -1073,7 +1076,7 @@ def detach_iso(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/detach_iso", + url=f"{self._base_url}/{server.id}/actions/detach_iso", method="POST", ) return BoundAction(self._parent.actions, response["action"]) @@ -1095,7 +1098,7 @@ def change_dns_ptr( """ data: dict[str, Any] = {"ip": ip, "dns_ptr": dns_ptr} response = self._client.request( - url=f"/servers/{server.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{server.id}/actions/change_dns_ptr", method="POST", json=data, ) @@ -1123,7 +1126,7 @@ def change_protection( data.update({"rebuild": rebuild}) response = self._client.request( - url=f"/servers/{server.id}/actions/change_protection", + url=f"{self._base_url}/{server.id}/actions/change_protection", method="POST", json=data, ) @@ -1136,7 +1139,7 @@ def request_console(self, server: Server | BoundServer) -> RequestConsoleRespons :return: :class:`RequestConsoleResponse ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/request_console", + url=f"{self._base_url}/{server.id}/actions/request_console", method="POST", ) return RequestConsoleResponse( @@ -1168,7 +1171,7 @@ def attach_to_network( if alias_ips is not None: data.update({"alias_ips": alias_ips}) response = self._client.request( - url=f"/servers/{server.id}/actions/attach_to_network", + url=f"{self._base_url}/{server.id}/actions/attach_to_network", method="POST", json=data, ) @@ -1187,7 +1190,7 @@ def detach_from_network( """ data: dict[str, Any] = {"network": network.id} response = self._client.request( - url=f"/servers/{server.id}/actions/detach_from_network", + url=f"{self._base_url}/{server.id}/actions/detach_from_network", method="POST", json=data, ) @@ -1209,7 +1212,7 @@ def change_alias_ips( """ data: dict[str, Any] = {"network": network.id, "alias_ips": alias_ips} response = self._client.request( - url=f"/servers/{server.id}/actions/change_alias_ips", + url=f"{self._base_url}/{server.id}/actions/change_alias_ips", method="POST", json=data, ) @@ -1228,7 +1231,7 @@ def add_to_placement_group( """ data: dict[str, Any] = {"placement_group": placement_group.id} response = self._client.request( - url=f"/servers/{server.id}/actions/add_to_placement_group", + url=f"{self._base_url}/{server.id}/actions/add_to_placement_group", method="POST", json=data, ) @@ -1241,7 +1244,7 @@ def remove_from_placement_group(self, server: Server | BoundServer) -> BoundActi :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/remove_from_placement_group", + url=f"{self._base_url}/{server.id}/actions/remove_from_placement_group", method="POST", ) return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index 5fe355c1..d992c2f5 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -39,6 +39,7 @@ class SSHKeysPageResult(NamedTuple): class SSHKeysClient(ResourceClientBase): + _base_url = "/ssh_keys" def get_by_id(self, id: int) -> BoundSSHKey: """Get a specific SSH Key by its ID @@ -46,7 +47,7 @@ def get_by_id(self, id: int) -> BoundSSHKey: :param id: int :return: :class:`BoundSSHKey ` """ - response = self._client.request(url=f"/ssh_keys/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundSSHKey(self, response["ssh_key"]) def get_list( @@ -83,7 +84,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/ssh_keys", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) ssh_keys = [ BoundSSHKey(self, server_data) for server_data in response["ssh_keys"] @@ -149,7 +150,7 @@ def create( data: dict[str, Any] = {"name": name, "public_key": public_key} if labels is not None: data["labels"] = labels - response = self._client.request(url="/ssh_keys", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundSSHKey(self, response["ssh_key"]) def update( @@ -173,7 +174,7 @@ def update( if labels is not None: data["labels"] = labels response = self._client.request( - url=f"/ssh_keys/{ssh_key.id}", + url=f"{self._base_url}/{ssh_key.id}", method="PUT", json=data, ) @@ -185,6 +186,6 @@ def delete(self, ssh_key: SSHKey | BoundSSHKey) -> bool: :param ssh_key: :class:`BoundSSHKey ` or :class:`SSHKey ` :return: True """ - self._client.request(url=f"/ssh_keys/{ssh_key.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{ssh_key.id}", method="DELETE") # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised return True diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 2420d7d2..dd5b7458 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -136,6 +136,7 @@ class VolumesPageResult(NamedTuple): class VolumesClient(ResourceClientBase): + _base_url = "/volumes" actions: ResourceActionsClient """Volumes scoped actions client @@ -145,7 +146,7 @@ class VolumesClient(ResourceClientBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/volumes") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundVolume: """Get a specific volume by its id @@ -153,7 +154,7 @@ def get_by_id(self, id: int) -> BoundVolume: :param id: int :return: :class:`BoundVolume ` """ - response = self._client.request(url=f"/volumes/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundVolume(self, response["volume"]) def get_list( @@ -190,7 +191,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/volumes", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) volumes = [ BoundVolume(self, volume_data) for volume_data in response["volumes"] ] @@ -270,7 +271,7 @@ def create( if format is not None: data["format"] = format - response = self._client.request(url="/volumes", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") result = CreateVolumeResponse( volume=BoundVolume(self, response["volume"]), @@ -314,7 +315,7 @@ def get_actions_list( params["per_page"] = per_page response = self._client.request( - url=f"/volumes/{volume.id}/actions", + url=f"{self._base_url}/{volume.id}/actions", method="GET", params=params, ) @@ -367,7 +368,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/volumes/{volume.id}", + url=f"{self._base_url}/{volume.id}", method="PUT", json=data, ) @@ -379,7 +380,7 @@ def delete(self, volume: Volume | BoundVolume) -> bool: :param volume: :class:`BoundVolume ` or :class:`Volume ` :return: boolean """ - self._client.request(url=f"/volumes/{volume.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{volume.id}", method="DELETE") return True def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: @@ -391,7 +392,7 @@ def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: :return: :class:`BoundAction ` """ data = self._client.request( - url=f"/volumes/{volume.id}/actions/resize", + url=f"{self._base_url}/{volume.id}/actions/resize", json={"size": size}, method="POST", ) @@ -415,7 +416,7 @@ def attach( data["automount"] = automount data = self._client.request( - url=f"/volumes/{volume.id}/actions/attach", + url=f"{self._base_url}/{volume.id}/actions/attach", json=data, method="POST", ) @@ -428,7 +429,7 @@ def detach(self, volume: Volume | BoundVolume) -> BoundAction: :return: :class:`BoundAction ` """ data = self._client.request( - url=f"/volumes/{volume.id}/actions/detach", + url=f"{self._base_url}/{volume.id}/actions/detach", method="POST", ) return BoundAction(self._parent.actions, data["action"]) @@ -450,7 +451,7 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/volumes/{volume.id}/actions/change_protection", + url=f"{self._base_url}/{volume.id}/actions/change_protection", method="POST", json=data, ) From 14ecce22c833c7c1954b33762359af42d5c22be4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 06:48:28 +0200 Subject: [PATCH 090/213] chore(deps): update apricote/releaser-pleaser action to v0.7.0 (#548) --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 7ad3ec9c..2d665a95 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.6.1 + uses: apricote/releaser-pleaser@v0.7.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From 30b50d1170486168236f5a6953ae94a9923d37c1 Mon Sep 17 00:00:00 2001 From: BonifacioCalindoro <91814132+BonifacioCalindoro@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:19:45 +0200 Subject: [PATCH 091/213] docs: fix server metrics example (#547) The existing examples/get_server_metrics.py version has 2 problems: - the iso-formatted dates need a timezone in order to work with the API - Object of type Metrics is not JSON serializable anymore. So i added an explicit UTC timezone for the datetime objects, and got rid of the json.dumps() at the end of the script. --- examples/get_server_metrics.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/examples/get_server_metrics.py b/examples/get_server_metrics.py index 16b961ca..55f6b9ab 100644 --- a/examples/get_server_metrics.py +++ b/examples/get_server_metrics.py @@ -1,7 +1,6 @@ from __future__ import annotations -import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from os import environ from hcloud import Client @@ -24,7 +23,7 @@ ) server = response.server -end = datetime.now() +end = datetime.now(timezone.utc) start = end - timedelta(hours=1) response = server.get_metrics( @@ -33,4 +32,4 @@ end=end, ) -print(json.dumps(response.metrics)) +print(response.metrics) From e475af7b9df2184f6a168fc87f2a650629cf5748 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 8 Sep 2025 11:57:38 +0200 Subject: [PATCH 092/213] feat: add category property to server type (#549) Related to https://docs.hetzner.cloud/changelog#2025-08-25-category-for-server-types --- hcloud/server_types/domain.py | 5 +++++ tests/unit/server_types/conftest.py | 1 + tests/unit/server_types/test_client.py | 1 + 3 files changed, 7 insertions(+) diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index c2031c6a..ad5ca4b2 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -15,6 +15,8 @@ class ServerType(BaseDomain, DomainIdentityMixin): Unique identifier of the server type :param description: str Description of the server type + :param category: str + Category of the Server Type. :param cores: int Number of cpu cores a server of this type will have :param memory: int @@ -42,6 +44,7 @@ class ServerType(BaseDomain, DomainIdentityMixin): "id", "name", "description", + "category", "cores", "memory", "disk", @@ -66,6 +69,7 @@ def __init__( id: int | None = None, name: str | None = None, description: str | None = None, + category: str | None = None, cores: int | None = None, memory: int | None = None, disk: int | None = None, @@ -80,6 +84,7 @@ def __init__( self.id = id self.name = name self.description = description + self.category = category self.cores = cores self.memory = memory self.disk = disk diff --git a/tests/unit/server_types/conftest.py b/tests/unit/server_types/conftest.py index 7f4b1344..8bb794a1 100644 --- a/tests/unit/server_types/conftest.py +++ b/tests/unit/server_types/conftest.py @@ -10,6 +10,7 @@ def server_type_response(): "id": 1, "name": "cx11", "description": "CX11", + "category": "Shared vCPU", "cores": 1, "memory": 1, "disk": 25, diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 7917746f..5802c41b 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -22,6 +22,7 @@ def test_bound_server_type_init(self, server_type_response): assert bound_server_type.id == 1 assert bound_server_type.name == "cx11" assert bound_server_type.description == "CX11" + assert bound_server_type.category == "Shared vCPU" assert bound_server_type.cores == 1 assert bound_server_type.memory == 1 assert bound_server_type.disk == 25 From f47aaf5a7d2177b162ca5479178278ad6966e04b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:01:53 +0200 Subject: [PATCH 093/213] chore(deps): update dependency pytest-cov to >=6,<6.4 (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [pytest-cov](https://redirect.github.com/pytest-dev/pytest-cov) ([changelog](https://pytest-cov.readthedocs.io/en/latest/changelog.html)) | `>=6,<6.3` -> `>=6,<6.4` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest-cov/6.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest-cov/6.2.1/6.3.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
pytest-dev/pytest-cov (pytest-cov) ### [`v6.3.0`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#630-2025-09-06) [Compare Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.2.1...v6.3.0) - Added support for markdown reports. Contributed by Marcos Boger in `#​712 `\_ and `#​714 `\_. - Fixed some formatting issues in docs. Anonymous contribution in `#​706 `\_.
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6a228229..6bd42d5d 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ "coverage>=7.10,<7.11", "pylint>=3,<3.4", "pytest>=8,<8.5", - "pytest-cov>=6,<6.3", + "pytest-cov>=6,<6.4", "mypy>=1.17,<1.18", "types-python-dateutil", "types-requests", From f95978fa68009974f2c3ff97a4f6377d020d2b1a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:02:10 +0200 Subject: [PATCH 094/213] chore(deps): update pypa/gh-action-pypi-publish action to v1.13.0 (#551) 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 | |---|---|---|---| | [pypa/gh-action-pypi-publish](https://redirect.github.com/pypa/gh-action-pypi-publish) | action | minor | `v1.12.4` -> `v1.13.0` | --- ### Release Notes
pypa/gh-action-pypi-publish (pypa/gh-action-pypi-publish) ### [`v1.13.0`](https://redirect.github.com/pypa/gh-action-pypi-publish/releases/tag/v1.13.0) [Compare Source](https://redirect.github.com/pypa/gh-action-pypi-publish/compare/v1.12.4...v1.13.0)

Take the 2025 Python Packaging Survey if you still haven't!

> \[!important] > 🚨 This release includes fixes for [GHSA-vxmw-7h4f-hqxh](https://redirect.github.com/pypa/gh-action-pypi-publish/security/advisories/GHSA-vxmw-7h4f-hqxh) discovered by [@​woodruffw](https://redirect.github.com/woodruffw)[πŸ’°](https://redirect.github.com/sponsors/woodruffw). > We've also integrated [Zizmor](http://zizmor.sh) to catch similar issues in the future and you should too. #### ✨ New Stuff [@​woodruffw](https://redirect.github.com/woodruffw)[πŸ’°](https://redirect.github.com/sponsors/woodruffw) updated the README to no longer mention the attestations feature being experimental in [#​347](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/347): it's been rather stable for a year already πŸŽ‰ He also added more diagnostic output which includes printing out the GitHub Environment claim via [#​371](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/371) and warning about the unsupported reusable workflows configurations [#​306](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/306), when using Trusted Publishing. > \[!tip] > The official support for reusable workflows is currently blocked on changes to PyPI. To get updates about progress on the action side, you may want to subscribe to [#​166](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/166). > At PyCon US 2025 Sprints, [@​facutuesca](https://redirect.github.com/facutuesca)[πŸ’°](https://redirect.github.com/sponsors/facutuesca), [@​miketheman](https://redirect.github.com/miketheman)[πŸ’°](https://redirect.github.com/sponsors/miketheman), [@​woodruffw](https://redirect.github.com/woodruffw)[πŸ’°](https://redirect.github.com/sponsors/woodruffw) and I[πŸ’°][GH Sponsors URL] spent several hours IRL brainstorming how to fix this and migrate projects that happen to rely on an obscure corner case with reusable workflows that temporarily allows them to function by accident. > The result of that discussion is posted @​ [pypi/warehouse#11096 (comment)](https://redirect.github.com/pypi/warehouse/issues/11096#issuecomment-2895081700). > Note that this is a volunteer-led effort and there is no ETA. If you need this soon, make your employer sponsor the PSF and maybe they'll be able to hire somebody for this work on Warehouse. In addition to that, [@​konstin](https://redirect.github.com/konstin)[πŸ’°](https://redirect.github.com/sponsors/konstin) sent [#​378](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/378) to pin `actions/setup-python` to a SHA hash. This makes `pypi-publish` compatible with new GitHub policies that allow organizations to mandate hash-pinning actions used in workflows. #### πŸ› οΈ Internal Dependencies [@​webknjaz](https://redirect.github.com/webknjaz)[πŸ’°][GH Sponsors URL] made a bunch of updates to the action runtime which includes bumping it to Python 3.13 in [#​331](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/331) and updating the dependency tree across the board. `pip-with-requires-python` is no longer being installed ([#​332](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/332)). Some related bumps were contributed by [@​woodruffw](https://redirect.github.com/woodruffw)[πŸ’°](https://redirect.github.com/sponsors/woodruffw) ([#​359](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/359)) and [@​kurtmckee](https://redirect.github.com/kurtmckee)[πŸ’°](https://redirect.github.com/sponsors/kurtmckee) sent a contributor-facing PR, bumping the linting configuration via [#​335](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/335). #### πŸ’ͺ New Contributors - [@​kurtmckee](https://redirect.github.com/kurtmckee) made their first contribution in [#​335](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/335) - [@​konstin](https://redirect.github.com/konstin) made their first contribution in [#​378](https://redirect.github.com/pypa/gh-action-pypi-publish/issues/378) **πŸͺž Full Diff**: **πŸ§”β€β™‚οΈ Release Manager:** [@​webknjaz](https://redirect.github.com/sponsors/webknjaz) [πŸ‡ΊπŸ‡¦](https://stand-with-ukraine.pp.ua) **πŸ’¬ Discuss** [on Bluesky πŸ¦‹](https://bsky.app/profile/webknjaz.me/post/3lxxzvzhvfc2e), [on Mastodon 🐘](https://mastodon.social/@​webknjaz/115143522527224444) and [on GitHub][release discussion]. [![GH Sponsors badge]][GH Sponsors URL] [release discussion]: https://redirect.github.com/pypa/gh-action-pypi-publish/discussions/379 [GH Sponsors badge]: https://img.shields.io/badge/%40webknjaz-transparent?logo=githubsponsors&logoColor=%23EA4AAA&label=Sponsor&color=2a313c [GH Sponsors URL]: https://redirect.github.com/sponsors/webknjaz
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 61051123..21865c33 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.12.4 + uses: pypa/gh-action-pypi-publish@v1.13.0 From 74741e1dc0eae0b91400f326206d1d0fe403dc81 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:03:49 +0200 Subject: [PATCH 095/213] chore(deps): update actions/setup-python action to v6 (#552) 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 | |---|---|---|---| | [actions/setup-python](https://redirect.github.com/actions/setup-python) | action | major | `v5` -> `v6` | --- ### Release Notes
actions/setup-python (actions/setup-python) ### [`v6`](https://redirect.github.com/actions/setup-python/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/setup-python/compare/v5...v6)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 33948432..a16ee4da 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x @@ -28,7 +28,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21865c33..c65cc60f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.x diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 16cb6cff..14899fa4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v5 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} From dc3b3eb21b605e248cba6aaf514cf7a625263ae3 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 8 Sep 2025 12:13:40 +0200 Subject: [PATCH 096/213] chore(main): release v2.6.0 (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - add category property to server type (#549) ### Bug Fixes - rename `ClientEntityBase` to `ResourceClientBase` (#532) ---

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 <> Co-authored-by: jo --- CHANGELOG.md | 10 ++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7da24530..a4a394bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [v2.6.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.6.0) + +### Features + +- add category property to server type (#549) + +### Bug Fixes + +- rename `ClientEntityBase` to `ResourceClientBase` (#532) + ## [v2.5.4](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.4) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index b8c5f786..206817ed 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.5.4" # x-releaser-pleaser-version +__version__ = "2.6.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 6bd42d5d..98f394b3 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.5.4", # x-releaser-pleaser-version + version="2.6.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 4f819dbf7aeb5a801594dfa28dbbca4c2db32f57 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 20 Sep 2025 01:36:21 +0000 Subject: [PATCH 097/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v25.9.0 (#556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs from Renovate will soon appear from 'Mend'. Learn more [here](https://redirect.github.com/renovatebot/renovate/discussions/37842). 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 | `25.1.0` -> `25.9.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) ### [`v25.9.0`](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.0) [Compare Source](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.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 d1ebf69e..23155f8c 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: 25.1.0 + rev: 25.9.0 hooks: - id: black From ed6b0a27f54571f29f8e9664426b0cdd53ae3512 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:05:03 +0200 Subject: [PATCH 098/213] chore(deps): update dependency pytest-cov to v7 (#554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs from Renovate will soon appear from 'Mend'. Learn more [here](https://redirect.github.com/renovatebot/renovate/discussions/37842). This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [pytest-cov](https://redirect.github.com/pytest-dev/pytest-cov) ([changelog](https://pytest-cov.readthedocs.io/en/latest/changelog.html)) | `>=6,<6.4` -> `>=7,<7.1` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/pytest-cov/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pytest-cov/6.3.0/7.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
pytest-dev/pytest-cov (pytest-cov) ### [`v7.0.0`](https://redirect.github.com/pytest-dev/pytest-cov/blob/HEAD/CHANGELOG.rst#700-2025-09-09) [Compare Source](https://redirect.github.com/pytest-dev/pytest-cov/compare/v6.3.0...v7.0.0) - Dropped support for subprocesses measurement. It was a feature added long time ago when coverage lacked a nice way to measure subprocesses created in tests. It relied on a `.pth` file, there was no way to opt-out and it created bad interations with `coverage's new patch system `\_ added in `7.10 `\_. To migrate to this release you might need to enable the suprocess patch, example for `.coveragerc`: .. code-block:: ini \[run] patch = subprocess This release also requires at least coverage 7.10.6. - Switched packaging to have metadata completely in `pyproject.toml` and use `hatchling `\_ for building. Contributed by Ofek Lev in `#​551 `\_ with some extras in `#​716 `\_. - Removed some not really necessary testing deps like `six`.
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 98f394b3..5b103944 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ "coverage>=7.10,<7.11", "pylint>=3,<3.4", "pytest>=8,<8.5", - "pytest-cov>=6,<6.4", + "pytest-cov>=7,<7.1", "mypy>=1.17,<1.18", "types-python-dateutil", "types-requests", From 2134cb8c09601447b7d02c55325c0373c187d574 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:10:34 +0200 Subject: [PATCH 099/213] chore(deps): update dependency mypy to >=1.18,<1.19 (#555) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs from Renovate will soon appear from 'Mend'. Learn more [here](https://redirect.github.com/renovatebot/renovate/discussions/37842). This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [mypy](https://redirect.github.com/python/mypy) ([changelog](https://mypy.readthedocs.io/en/latest/changelog.html)) | `>=1.17,<1.18` -> `>=1.18,<1.19` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/mypy/1.18.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/mypy/1.17.1/1.18.2?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
python/mypy (mypy) ### [`v1.18.2`](https://redirect.github.com/python/mypy/blob/HEAD/CHANGELOG.md#Mypy-1182) [Compare Source](https://redirect.github.com/python/mypy/compare/v1.18.1...v1.18.2) - Fix crash on recursive alias (Ivan Levkivskyi, PR [19845](https://redirect.github.com/python/mypy/pull/19845)) - Add additional guidance for stubtest errors when runtime is `object.__init__` (Stephen Morton, PR [19733](https://redirect.github.com/python/mypy/pull/19733)) - Fix handling of None values in f-string expressions in mypyc (BobTheBuidler, PR [19846](https://redirect.github.com/python/mypy/pull/19846)) ### [`v1.18.1`](https://redirect.github.com/python/mypy/compare/v1.17.1...v1.18.1) [Compare Source](https://redirect.github.com/python/mypy/compare/v1.17.1...v1.18.1)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5b103944..e956926b 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=3,<3.4", "pytest>=8,<8.5", "pytest-cov>=7,<7.1", - "mypy>=1.17,<1.18", + "mypy>=1.18,<1.19", "types-python-dateutil", "types-requests", ], From 628aa6d562c9c78c226afc8df97994ec8d5c4f04 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 24 Sep 2025 10:50:47 +0200 Subject: [PATCH 100/213] docs: add experimental features maturity (#557) Add the API upstream maturity to our experimental features release process. --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 066e2906..07666cbd 100644 --- a/README.md +++ b/README.md @@ -66,12 +66,17 @@ Experimental features are published as part of our regular releases (e.g. a prod public beta). During an experimental phase, breaking changes on those features may occur within minor releases. +The stability of experimental features is not related to the stability of its upstream API. + +Experimental features have different levels of maturity (e.g. experimental, alpha, beta) +based on the maturity of the upstream API. + While experimental features will be announced in the release notes, you can also find whether a python class or function is experimental in its docstring: ``` Experimental: - $PRODUCT is experimental, breaking changes may occur within minor releases. + $PRODUCT is $MATURITY, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#$SLUG for more details. ``` @@ -151,7 +156,7 @@ To publish experimental features as part of regular releases: ```py """ Experimental: - $PRODUCT is experimental, breaking changes may occur within minor releases. + $PRODUCT is $MATURITY, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#$SLUG for more details. """ ``` From a77b0cc9c4287261941ffc2369a6e062319a8384 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 25 Sep 2025 09:53:40 +0200 Subject: [PATCH 101/213] feat: per location server types (#558) [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). - We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. - We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. **Upgrading** ```py def validate_server_type(server_type: ServerType): if server_type.deprecation is not None: raise ValueError(f"server type {server_type.name} is deprecated") ``` ```py def validate_server_type(server_type: ServerType, location: Location): found = [o for o in server_type.locations if location.name == o.location.name] if not found: raise ValueError( f"server type {server_type.name} is not supported in location {location.name}" ) server_type_location = found[0] if server_type_location.deprecation is not None: raise ValueError( f"server type {server_type.name} is deprecated in location {location.name}" ) ``` --- hcloud/server_types/__init__.py | 3 +- hcloud/server_types/client.py | 25 +++++++- hcloud/server_types/domain.py | 82 +++++++++++++++++++++++++- tests/unit/server_types/conftest.py | 27 +++++++-- tests/unit/server_types/test_client.py | 51 ++++++++++------ 5 files changed, 159 insertions(+), 29 deletions(-) diff --git a/hcloud/server_types/__init__.py b/hcloud/server_types/__init__.py index 2cdab177..6840e327 100644 --- a/hcloud/server_types/__init__.py +++ b/hcloud/server_types/__init__.py @@ -5,11 +5,12 @@ ServerTypesClient, ServerTypesPageResult, ) -from .domain import ServerType +from .domain import ServerType, ServerTypeLocation __all__ = [ "BoundServerType", "ServerType", + "ServerTypeLocation", "ServerTypesClient", "ServerTypesPageResult", ] diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 085b66f8..bfa525c2 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -3,7 +3,8 @@ from typing import Any, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase -from .domain import ServerType +from ..locations import BoundLocation +from .domain import ServerType, ServerTypeLocation class BoundServerType(BoundModelBase, ServerType): @@ -11,6 +12,28 @@ class BoundServerType(BoundModelBase, ServerType): model = ServerType + def __init__( + self, + client: ServerTypesClient, + data: dict, + complete: bool = True, + ): + raw = data.get("locations") + if raw is not None: + data["locations"] = [ + ServerTypeLocation.from_dict( + { + "location": BoundLocation( + client._parent.locations, o, complete=False + ), + **o, + } + ) + for o in raw + ] + + super().__init__(client, data, complete) + class ServerTypesPageResult(NamedTuple): server_types: list[BoundServerType] diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index ad5ca4b2..ff9e3fd4 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -4,6 +4,7 @@ from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo +from ..locations import BoundLocation class ServerType(BaseDomain, DomainIdentityMixin): @@ -38,6 +39,7 @@ class ServerType(BaseDomain, DomainIdentityMixin): deprecated. If it has a value, it is considered deprecated. :param included_traffic: int Free traffic per month in bytes + :param locations: Supported Location of the Server Type. """ __properties__ = ( @@ -52,18 +54,22 @@ class ServerType(BaseDomain, DomainIdentityMixin): "storage_type", "cpu_type", "architecture", - "deprecated", - "deprecation", + "locations", ) __api_properties__ = ( *__properties__, + "deprecated", + "deprecation", "included_traffic", ) __slots__ = ( *__properties__, + "_deprecated", + "_deprecation", "_included_traffic", ) + # pylint: disable=too-many-locals def __init__( self, id: int | None = None, @@ -80,6 +86,7 @@ def __init__( deprecated: bool | None = None, deprecation: dict | None = None, included_traffic: int | None = None, + locations: list[ServerTypeLocation] | None = None, ): self.id = id self.name = name @@ -92,12 +99,58 @@ def __init__( self.storage_type = storage_type self.cpu_type = cpu_type self.architecture = architecture + self.locations = locations + self.deprecated = deprecated self.deprecation = ( DeprecationInfo.from_dict(deprecation) if deprecation is not None else None ) self.included_traffic = included_traffic + @property + def deprecated(self) -> bool | None: + """ + .. deprecated:: 2.6.0 + The 'deprecated' property is deprecated and will gradually be phased starting 24 September 2025. + Please refer to the '.locations[].deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types. + """ + warnings.warn( + "The 'deprecated' property is deprecated and will gradually be phased starting 24 September 2025. " + "Please refer to the '.locations[].deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types", + DeprecationWarning, + stacklevel=2, + ) + return self._deprecated + + @deprecated.setter + def deprecated(self, value: bool | None) -> None: + self._deprecated = value + + @property + def deprecation(self) -> DeprecationInfo | None: + """ + .. deprecated:: 2.6.0 + The 'deprecation' property is deprecated and will gradually be phased starting 24 September 2025. + Please refer to the '.locations[].deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types. + """ + warnings.warn( + "The 'deprecation' property is deprecated and will gradually be phased starting 24 September 2025. " + "Please refer to the '.locations[].deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types", + DeprecationWarning, + stacklevel=2, + ) + return self._deprecation + + @deprecation.setter + def deprecation(self, value: DeprecationInfo | None) -> None: + self._deprecation = value + @property def included_traffic(self) -> int | None: """ @@ -119,3 +172,28 @@ def included_traffic(self) -> int | None: @included_traffic.setter def included_traffic(self, value: int | None) -> None: self._included_traffic = value + + +class ServerTypeLocation(BaseDomain): + """Server Type Location Domain + + :param location: Location of the Server Type. + :param deprecation: Wether the Server Type is deprecated in this Location. + """ + + __api_properties__ = ( + "location", + "deprecation", + ) + __slots__ = __api_properties__ + + def __init__( + self, + *, + location: BoundLocation, + deprecation: dict | None, + ): + self.location = location + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) diff --git a/tests/unit/server_types/conftest.py b/tests/unit/server_types/conftest.py index 8bb794a1..5ff2b5e5 100644 --- a/tests/unit/server_types/conftest.py +++ b/tests/unit/server_types/conftest.py @@ -33,9 +33,24 @@ def server_type_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, + "locations": [ + { + "id": 1, + "name": "nbg1", + "deprecation": None, + }, + { + "id": 2, + "name": "fsn1", + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + }, + ], } } @@ -70,8 +85,8 @@ def two_server_types_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, }, { @@ -146,8 +161,8 @@ def one_server_types_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, } ] diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 5802c41b..cdba00f1 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -14,31 +14,44 @@ class TestBoundServerType: def bound_server_type(self, client: Client): return BoundServerType(client.server_types, data=dict(id=14)) - def test_bound_server_type_init(self, server_type_response): - bound_server_type = BoundServerType( + def test_init(self, server_type_response): + o = BoundServerType( client=mock.MagicMock(), data=server_type_response["server_type"] ) - assert bound_server_type.id == 1 - assert bound_server_type.name == "cx11" - assert bound_server_type.description == "CX11" - assert bound_server_type.category == "Shared vCPU" - assert bound_server_type.cores == 1 - assert bound_server_type.memory == 1 - assert bound_server_type.disk == 25 - assert bound_server_type.storage_type == "local" - assert bound_server_type.cpu_type == "shared" - assert bound_server_type.architecture == "x86" - assert bound_server_type.deprecated is True - assert bound_server_type.deprecation is not None - assert bound_server_type.deprecation.announced == datetime( - 2023, 6, 1, tzinfo=timezone.utc + assert o.id == 1 + assert o.name == "cx11" + assert o.description == "CX11" + assert o.category == "Shared vCPU" + assert o.cores == 1 + assert o.memory == 1 + assert o.disk == 25 + assert o.storage_type == "local" + assert o.cpu_type == "shared" + assert o.architecture == "x86" + assert len(o.locations) == 2 + assert o.locations[0].location.id == 1 + assert o.locations[0].location.name == "nbg1" + assert o.locations[0].deprecation is None + assert o.locations[1].location.id == 2 + assert o.locations[1].location.name == "fsn1" + assert ( + o.locations[1].deprecation.announced.isoformat() + == "2023-06-01T00:00:00+00:00" ) - assert bound_server_type.deprecation.unavailable_after == datetime( - 2023, 9, 1, tzinfo=timezone.utc + assert ( + o.locations[1].deprecation.unavailable_after.isoformat() + == "2023-09-01T00:00:00+00:00" ) + with pytest.deprecated_call(): - assert bound_server_type.included_traffic == 21990232555520 + assert o.deprecated is True + assert o.deprecation is not None + assert o.deprecation.announced == datetime(2023, 6, 1, tzinfo=timezone.utc) + assert o.deprecation.unavailable_after == datetime( + 2023, 9, 1, tzinfo=timezone.utc + ) + assert o.included_traffic == 21990232555520 class TestServerTypesClient: From 43548859b28d460d05a3ad6f6a21be53e546fddf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 26 Sep 2025 09:53:24 +0200 Subject: [PATCH 102/213] chore(deps): update apricote/releaser-pleaser action to v0.7.1 (#560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs from Renovate will soon appear from 'Mend'. Learn more [here](https://redirect.github.com/renovatebot/renovate/discussions/37842). This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [apricote/releaser-pleaser](https://redirect.github.com/apricote/releaser-pleaser) | action | patch | `v0.7.0` -> `v0.7.1` | --- ### Release Notes
apricote/releaser-pleaser (apricote/releaser-pleaser) ### [`v0.7.1`](https://redirect.github.com/apricote/releaser-pleaser/blob/HEAD/CHANGELOG.md#v071) [Compare Source](https://redirect.github.com/apricote/releaser-pleaser/compare/v0.7.0...v0.7.1) ##### Bug Fixes - using code blocks within release-notes ([#​275](https://redirect.github.com/apricote/releaser-pleaser/issues/275)) - no html escaping for changelog template ([#​277](https://redirect.github.com/apricote/releaser-pleaser/issues/277))
--- ### 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> --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 2d665a95..699110e4 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.7.0 + uses: apricote/releaser-pleaser@v0.7.1 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From bbee5a71676a64559eee280528b50424833675a5 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 26 Sep 2025 10:02:23 +0200 Subject: [PATCH 103/213] chore(main): release v2.7.0 (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). - We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. - We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. **Upgrading** ```py # Before def validate_server_type(server_type: ServerType): if server_type.deprecation is not None: raise ValueError(f"server type {server_type.name} is deprecated") ``` ```py # After def validate_server_type(server_type: ServerType, location: Location): found = [o for o in server_type.locations if location.name == o.location.name] if not found: raise ValueError( f"server type {server_type.name} is not supported in location {location.name}" ) server_type_location = found[0] if server_type_location.deprecation is not None: raise ValueError( f"server type {server_type.name} is deprecated in location {location.name}" ) ``` ### Features - per location server types (#558) ---

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 [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). - We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. - We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. **Upgrading** ```py # Before def validate_server_type(server_type: ServerType): if server_type.deprecation is not None: raise ValueError(f"server type {server_type.name} is deprecated") ``` ```py # After def validate_server_type(server_type: ServerType, location: Location): found = [o for o in server_type.locations if location.name == o.location.name] if not found: raise ValueError( f"server type {server_type.name} is not supported in location {location.name}" ) server_type_location = found[0] if server_type_location.deprecation is not None: raise ValueError( f"server type {server_type.name} is deprecated in location {location.name}" ) ``` ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4a394bf..be18ccdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## [v2.7.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.7.0) + +[Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). + +- We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. + +- We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. + +See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. + +**Upgrading** + +```py +# Before +def validate_server_type(server_type: ServerType): + if server_type.deprecation is not None: + raise ValueError(f"server type {server_type.name} is deprecated") +``` + +```py +# After +def validate_server_type(server_type: ServerType, location: Location): + found = [o for o in server_type.locations if location.name == o.location.name] + if not found: + raise ValueError( + f"server type {server_type.name} is not supported in location {location.name}" + ) + + server_type_location = found[0] + + if server_type_location.deprecation is not None: + raise ValueError( + f"server type {server_type.name} is deprecated in location {location.name}" + ) +``` + +### Features + +- per location server types (#558) + ## [v2.6.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.6.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 206817ed..c99e8018 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.6.0" # x-releaser-pleaser-version +__version__ = "2.7.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index e956926b..6f75899c 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.6.0", # x-releaser-pleaser-version + version="2.7.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 18466c45ddd2c6327910928d322bcc8418c14e37 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 30 Sep 2025 12:51:27 +0200 Subject: [PATCH 104/213] feat: add new `ip_range` param to load balancer `attach_to_network` (#562) Related to https://docs.hetzner.cloud/changelog#2025-09-11-attach-to-network-support-ip-range --- hcloud/load_balancers/client.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 06625f27..9af3fa8d 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -319,15 +319,18 @@ def attach_to_network( self, network: Network | BoundNetwork, ip: str | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a Load Balancer to a Network :param network: :class:`BoundNetwork ` or :class:`Network ` :param ip: str IP to request to be assigned to this Load Balancer + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip) + return self._client.attach_to_network(self, network, ip, ip_range) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a Load Balancer from a Network. @@ -840,6 +843,7 @@ def attach_to_network( load_balancer: LoadBalancer | BoundLoadBalancer, network: Network | BoundNetwork, ip: str | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attach a Load Balancer to a Network. @@ -847,11 +851,15 @@ def attach_to_network( :param network: :class:`BoundNetwork ` or :class:`Network ` :param ip: str IP to request to be assigned to this Load Balancer + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ data: dict[str, Any] = {"network": network.id} if ip is not None: data.update({"ip": ip}) + if ip_range is not None: + data.update({"ip_range": ip_range}) response = self._client.request( url=f"{self._base_url}/{load_balancer.id}/actions/attach_to_network", From eb795242a8e9a02ab1bf98e4bbbb3c5ee9dfa154 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 30 Sep 2025 12:51:39 +0200 Subject: [PATCH 105/213] feat: add new `ip_range` param to server `attach_to_network` (#561) Related to https://docs.hetzner.cloud/changelog#2025-09-11-attach-to-network-support-ip-range --- hcloud/servers/client.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 55347d5e..cf43112b 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -424,6 +424,7 @@ def attach_to_network( network: Network | BoundNetwork, ip: str | None = None, alias_ips: list[str] | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a server to a network @@ -432,9 +433,11 @@ def attach_to_network( IP to request to be assigned to this server :param alias_ips: List[str] New alias IPs to set for this server. + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip, alias_ips) + return self._client.attach_to_network(self, network, ip, alias_ips, ip_range) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a server from a network. @@ -1154,6 +1157,7 @@ def attach_to_network( network: Network | BoundNetwork, ip: str | None = None, alias_ips: list[str] | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a server to a network @@ -1163,6 +1167,8 @@ def attach_to_network( IP to request to be assigned to this server :param alias_ips: List[str] New alias IPs to set for this server. + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ data: dict[str, Any] = {"network": network.id} @@ -1170,6 +1176,9 @@ def attach_to_network( data.update({"ip": ip}) if alias_ips is not None: data.update({"alias_ips": alias_ips}) + if ip_range is not None: + data.update({"ip_range": ip_range}) + response = self._client.request( url=f"{self._base_url}/{server.id}/actions/attach_to_network", method="POST", From 61ec52dea55dd04683aa92c9efc5dfc79702a55c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 2 Oct 2025 01:38:01 +0000 Subject: [PATCH 106/213] chore(deps): update pre-commit hook pycqa/isort to v6.1.0 (#565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > Mend has cancelled [the proposed renaming](https://redirect.github.com/renovatebot/renovate/discussions/37842) of the Renovate GitHub app being renamed to `mend[bot]`. > > This notice will be removed on 2025-10-07.
This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [pycqa/isort](https://redirect.github.com/pycqa/isort) | repository | minor | `6.0.1` -> `6.1.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
pycqa/isort (pycqa/isort) ### [`v6.1.0`](https://redirect.github.com/PyCQA/isort/releases/tag/6.1.0) [Compare Source](https://redirect.github.com/pycqa/isort/compare/6.0.1...6.1.0) #### Changes - Update docs discussions channel ([#​2410](https://redirect.github.com/pycqa/isort/issues/2410)) [@​staticdev](https://redirect.github.com/staticdev) - Add python 3.14 classifier and badge ([#​2409](https://redirect.github.com/pycqa/isort/issues/2409)) [@​staticdev](https://redirect.github.com/staticdev) - Drop use of non-standard pkg\_resources API ([#​2405](https://redirect.github.com/pycqa/isort/issues/2405)) [@​dvarrazzo](https://redirect.github.com/dvarrazzo) - Use working isort version in pre-commit example ([#​2402](https://redirect.github.com/pycqa/isort/issues/2402)) [@​iainelder](https://redirect.github.com/iainelder) - fix typo in \_get\_files\_from\_dir\_cached test ([#​2392](https://redirect.github.com/pycqa/isort/issues/2392)) [@​tiltingpenguin](https://redirect.github.com/tiltingpenguin) - Resolve bandit warnings ([#​2379](https://redirect.github.com/pycqa/isort/issues/2379)) [@​kurtmckee](https://redirect.github.com/kurtmckee) - Add tox for cross-platform, parallel test suite execution ([#​2378](https://redirect.github.com/pycqa/isort/issues/2378)) [@​kurtmckee](https://redirect.github.com/kurtmckee) - Add Project URLs to PyPI Side Panel ([#​2387](https://redirect.github.com/pycqa/isort/issues/2387)) [@​guillermodotn](https://redirect.github.com/guillermodotn) - Fix typos ([#​2376](https://redirect.github.com/pycqa/isort/issues/2376)) [@​co63oc](https://redirect.github.com/co63oc) #### :construction\_worker: Continuous Integration - Add make bash scripts portable ([#​2377](https://redirect.github.com/pycqa/isort/issues/2377)) [@​staticdev](https://redirect.github.com/staticdev) #### :package: Dependencies - Bump actions/checkout from 4 to 5 in the github-actions group ([#​2406](https://redirect.github.com/pycqa/isort/issues/2406)) @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot) - Bump astral-sh/setup-uv from 5 to 6 in the github-actions group ([#​2395](https://redirect.github.com/pycqa/isort/issues/2395)) @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot)
--- ### 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 23155f8c..8bc9e73d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--py39-plus] - repo: https://github.com/pycqa/isort - rev: 6.0.1 + rev: 6.1.0 hooks: - id: isort From 63fe718ee2a611aa4868ecdbbfabce8c83d30e3d Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 2 Oct 2025 10:01:43 +0200 Subject: [PATCH 107/213] refactor: use kwargs when proxying bound model client (#564) Related to #546 --- hcloud/certificates/client.py | 20 +++++++++++--- hcloud/firewalls/client.py | 22 +++++++++++----- hcloud/floating_ips/client.py | 20 +++++++++----- hcloud/images/client.py | 18 ++++++++++--- hcloud/load_balancers/client.py | 35 +++++++++++++++--------- hcloud/networks/client.py | 10 +++++-- hcloud/placement_groups/client.py | 2 +- hcloud/primary_ips/client.py | 13 ++++++--- hcloud/servers/client.py | 44 ++++++++++++++++++++++--------- hcloud/ssh_keys/client.py | 2 +- hcloud/volumes/client.py | 14 +++++----- 11 files changed, 142 insertions(+), 58 deletions(-) diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 9b75d9f3..9404513e 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -53,7 +53,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -68,7 +74,11 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -83,7 +93,11 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundCertificate ` """ - return self._client.update(self, name, labels) + return self._client.update( + self, + name=name, + labels=labels, + ) def delete(self) -> bool: """Deletes a certificate. diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 84535184..771f3845 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -109,7 +109,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -125,7 +131,11 @@ def get_actions( :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -140,7 +150,7 @@ def update( New Name to set :return: :class:`BoundFirewall ` """ - return self._client.update(self, labels, name) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a Firewall. @@ -155,7 +165,7 @@ def set_rules(self, rules: list[FirewallRule]) -> list[BoundAction]: :return: List[:class:`BoundAction `] """ - return self._client.set_rules(self, rules) + return self._client.set_rules(self, rules=rules) def apply_to_resources( self, @@ -165,7 +175,7 @@ def apply_to_resources( :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - return self._client.apply_to_resources(self, resources) + return self._client.apply_to_resources(self, resources=resources) def remove_from_resources( self, @@ -175,7 +185,7 @@ def remove_from_resources( :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - return self._client.remove_from_resources(self, resources) + return self._client.remove_from_resources(self, resources=resources) class FirewallsPageResult(NamedTuple): diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index cc876803..15432d1a 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -55,7 +55,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -70,7 +76,7 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -88,7 +94,9 @@ def update( New Name to set :return: :class:`BoundFloatingIP ` """ - return self._client.update(self, description, labels, name) + return self._client.update( + self, description=description, labels=labels, name=name + ) def delete(self) -> bool: """Deletes a Floating IP. If it is currently assigned to a server it will automatically get unassigned. @@ -104,7 +112,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the Floating IP from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def assign(self, server: Server | BoundServer) -> BoundAction: """Assigns a Floating IP to a server. @@ -113,7 +121,7 @@ def assign(self, server: Server | BoundServer) -> BoundAction: Server the Floating IP shall be assigned to :return: :class:`BoundAction ` """ - return self._client.assign(self, server) + return self._client.assign(self, server=server) def unassign(self) -> BoundAction: """Unassigns a Floating IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -131,7 +139,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) class FloatingIPsPageResult(NamedTuple): diff --git a/hcloud/images/client.py b/hcloud/images/client.py index 5d993336..3933ebe4 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -53,7 +53,11 @@ def get_actions_list( :return: (List[:class:`BoundAction `], :class:`Meta `) """ return self._client.get_actions_list( - self, sort=sort, page=page, per_page=per_page, status=status + self, + sort=sort, + page=page, + per_page=per_page, + status=status, ) def get_actions( @@ -69,7 +73,11 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status=status, sort=sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -88,7 +96,9 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundImage ` """ - return self._client.update(self, description, type, labels) + return self._client.update( + self, description=description, type=type, labels=labels + ) def delete(self) -> bool: """Deletes an Image. Only images of type snapshot and backup can be deleted. @@ -104,7 +114,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the snapshot from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) class ImagesPageResult(NamedTuple): diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 9af3fa8d..1317b732 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -174,7 +174,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundLoadBalancer ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a Load Balancer. @@ -224,7 +224,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -239,7 +245,7 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def add_service(self, service: LoadBalancerService) -> BoundAction: """Adds a service to a Load Balancer. @@ -266,7 +272,7 @@ def delete_service(self, service: LoadBalancerService) -> BoundAction: The LoadBalancerService you want to delete from the Load Balancer :return: :class:`BoundAction ` """ - return self._client.delete_service(self, service) + return self._client.delete_service(self, service=service) def add_target(self, target: LoadBalancerTarget) -> BoundAction: """Adds a target to a Load Balancer. @@ -275,7 +281,7 @@ def add_target(self, target: LoadBalancerTarget) -> BoundAction: The LoadBalancerTarget you want to add to the Load Balancer :return: :class:`BoundAction ` """ - return self._client.add_target(self, target) + return self._client.add_target(self, target=target) def remove_target(self, target: LoadBalancerTarget) -> BoundAction: """Removes a target from a Load Balancer. @@ -284,7 +290,7 @@ def remove_target(self, target: LoadBalancerTarget) -> BoundAction: The LoadBalancerTarget you want to remove from the Load Balancer :return: :class:`BoundAction ` """ - return self._client.remove_target(self, target) + return self._client.remove_target(self, target=target) def change_algorithm(self, algorithm: LoadBalancerAlgorithm) -> BoundAction: """Changes the algorithm used by the Load Balancer @@ -293,7 +299,7 @@ def change_algorithm(self, algorithm: LoadBalancerAlgorithm) -> BoundAction: The LoadBalancerAlgorithm you want to use :return: :class:`BoundAction ` """ - return self._client.change_algorithm(self, algorithm) + return self._client.change_algorithm(self, algorithm=algorithm) def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to the public IPs (IPv4 and IPv6) of this Load Balancer. @@ -304,7 +310,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) def change_protection(self, delete: bool) -> BoundAction: """Changes the protection configuration of a Load Balancer. @@ -313,7 +319,7 @@ def change_protection(self, delete: bool) -> BoundAction: If True, prevents the Load Balancer from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def attach_to_network( self, @@ -330,7 +336,12 @@ def attach_to_network( IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip, ip_range) + return self._client.attach_to_network( + self, + network=network, + ip=ip, + ip_range=ip_range, + ) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a Load Balancer from a Network. @@ -338,7 +349,7 @@ def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.detach_from_network(self, network) + return self._client.detach_from_network(self, network=network) def enable_public_interface(self) -> BoundAction: """Enables the public interface of a Load Balancer. @@ -364,7 +375,7 @@ def change_type( Load Balancer type the Load Balancer should migrate to :return: :class:`BoundAction ` """ - return self._client.change_type(self, load_balancer_type) + return self._client.change_type(self, load_balancer_type=load_balancer_type) class LoadBalancersPageResult(NamedTuple): diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 5ac1b9d0..7c6846a8 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -89,7 +89,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -104,7 +110,7 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def add_subnet(self, subnet: NetworkSubnet) -> BoundAction: """Adds a subnet entry to a network. diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index dbae26df..de61330b 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -25,7 +25,7 @@ def update( New Name to set :return: :class:`BoundPlacementGroup ` """ - return self._client.update(self, labels, name) + return self._client.update(self, labels=labels, name=name) def delete(self) -> bool: """Deletes a Placement Group diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 497e8a5b..c6acf6ef 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -43,7 +43,10 @@ def update( :return: :class:`BoundPrimaryIP ` """ return self._client.update( - self, auto_delete=auto_delete, labels=labels, name=name + self, + auto_delete=auto_delete, + labels=labels, + name=name, ) def delete(self) -> bool: @@ -60,7 +63,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the Primary IP from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def assign(self, assignee_id: int, assignee_type: str) -> BoundAction: """Assigns a Primary IP to a assignee. @@ -71,7 +74,9 @@ def assign(self, assignee_id: int, assignee_type: str) -> BoundAction: Assignee type (e.g server) the Primary IP shall be assigned to :return: :class:`BoundAction ` """ - return self._client.assign(self, assignee_id, assignee_type) + return self._client.assign( + self, assignee_id=assignee_id, assignee_type=assignee_type + ) def unassign(self) -> BoundAction: """Unassigns a Primary IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -89,7 +94,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) class PrimaryIPsPageResult(NamedTuple): diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index cf43112b..faeed0be 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -183,7 +183,13 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, @@ -198,7 +204,7 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -213,7 +219,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundServer ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def get_metrics( self, @@ -327,7 +333,9 @@ def create_image( User-defined labels (key-value pairs) :return: :class:`CreateImageResponse ` """ - return self._client.create_image(self, description, type, labels) + return self._client.create_image( + self, description=description, type=type, labels=labels + ) def rebuild( self, @@ -339,7 +347,7 @@ def rebuild( :param image: Image to use for the rebuilt server """ - return self._client.rebuild(self, image) + return self._client.rebuild(self, image=image) def change_type( self, @@ -354,7 +362,9 @@ def change_type( If false, do not upgrade the disk. This allows downgrading the server type later. :return: :class:`BoundAction ` """ - return self._client.change_type(self, server_type, upgrade_disk) + return self._client.change_type( + self, server_type=server_type, upgrade_disk=upgrade_disk + ) def enable_backup(self) -> BoundAction: """Enables and configures the automatic daily backup option for the server. Enabling automatic backups will increase the price of the server by 20%. @@ -376,7 +386,7 @@ def attach_iso(self, iso: Iso | BoundIso) -> BoundAction: :param iso: :class:`BoundIso ` or :class:`Server ` :return: :class:`BoundAction ` """ - return self._client.attach_iso(self, iso) + return self._client.attach_iso(self, iso=iso) def detach_iso(self) -> BoundAction: """Detaches an ISO from a server. @@ -394,7 +404,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str | None) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) def change_protection( self, @@ -410,7 +420,7 @@ def change_protection( If true, prevents the server from being rebuilt (currently delete and rebuild attribute needs to have the same value) :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete, rebuild) + return self._client.change_protection(self, delete=delete, rebuild=rebuild) def request_console(self) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. @@ -437,7 +447,13 @@ def attach_to_network( IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip, alias_ips, ip_range) + return self._client.attach_to_network( + self, + network=network, + ip=ip, + alias_ips=alias_ips, + ip_range=ip_range, + ) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a server from a network. @@ -445,7 +461,7 @@ def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.detach_from_network(self, network) + return self._client.detach_from_network(self, network=network) def change_alias_ips( self, @@ -459,7 +475,7 @@ def change_alias_ips( New alias IPs to set for this server. :return: :class:`BoundAction ` """ - return self._client.change_alias_ips(self, network, alias_ips) + return self._client.change_alias_ips(self, network=network, alias_ips=alias_ips) def add_to_placement_group( self, @@ -470,7 +486,9 @@ def add_to_placement_group( :param placement_group: :class:`BoundPlacementGroup ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.add_to_placement_group(self, placement_group) + return self._client.add_to_placement_group( + self, placement_group=placement_group + ) def remove_from_placement_group(self) -> BoundAction: """Removes a server from a placement group. diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index d992c2f5..4fc541f5 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -24,7 +24,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundSSHKey ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes an SSH key. It cannot be used anymore. diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index dd5b7458..0b1b9667 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -52,7 +52,9 @@ def get_actions_list( Specifies how many results are returned by page :return: (List[:class:`BoundAction `], :class:`Meta `) """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, status=status, sort=sort, page=page, per_page=per_page + ) def get_actions( self, @@ -67,7 +69,7 @@ def get_actions( Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` :return: List[:class:`BoundAction `] """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -82,7 +84,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundAction ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a volume. All volume data is irreversibly destroyed. The volume must not be attached to a server and it must not have delete protection enabled. @@ -102,7 +104,7 @@ def attach( :param automount: boolean :return: :class:`BoundAction ` """ - return self._client.attach(self, server, automount) + return self._client.attach(self, server=server, automount=automount) def detach(self) -> BoundAction: """Detaches a volume from the server it’s attached to. You may attach it to a server again at a later time. @@ -118,7 +120,7 @@ def resize(self, size: int) -> BoundAction: New volume size in GB (must be greater than current size) :return: :class:`BoundAction ` """ - return self._client.resize(self, size) + return self._client.resize(self, size=size) def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of a volume. @@ -127,7 +129,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If True, prevents the volume from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) class VolumesPageResult(NamedTuple): From 4b3ac8cb933002c3b6eadf917634293b8fae1510 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 6 Oct 2025 09:59:22 +0200 Subject: [PATCH 108/213] fix: source_ips property is optional in firewall rule (#567) Source IPs are only required if the direction is `in`. Since we leave the API validate the data send, we must mark the source_ips property as optional. --- hcloud/firewalls/domain.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py index 44825f37..991eb8ba 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -97,7 +97,7 @@ def __init__( self, direction: str, protocol: str, - source_ips: list[str], + source_ips: list[str] | None = None, port: str | None = None, destination_ips: list[str] | None = None, description: str | None = None, @@ -105,7 +105,7 @@ def __init__( self.direction = direction self.port = port self.protocol = protocol - self.source_ips = source_ips + self.source_ips = source_ips or [] self.destination_ips = destination_ips or [] self.description = description @@ -116,8 +116,9 @@ def to_payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "direction": self.direction, "protocol": self.protocol, - "source_ips": self.source_ips, } + if len(self.source_ips) > 0: + payload["source_ips"] = self.source_ips if len(self.destination_ips) > 0: payload["destination_ips"] = self.destination_ips if self.port is not None: From fe49a672babaaf52b377b1d11e65d6ecefce6d9f Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 7 Oct 2025 10:15:11 +0200 Subject: [PATCH 109/213] feat: support the new DNS API (#568) Add support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). The DNS API is currently in **beta**. See the [DNS API beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. --- docs/api.clients.zones.rst | 30 + hcloud/_client.py | 7 + hcloud/zones/__init__.py | 29 + hcloud/zones/client.py | 1428 +++++++++++++++++++++++++++++ hcloud/zones/domain.py | 430 +++++++++ tests/unit/actions/test_client.py | 14 +- tests/unit/conftest.py | 12 + tests/unit/zones/__init__.py | 0 tests/unit/zones/conftest.py | 149 +++ tests/unit/zones/test_client.py | 928 +++++++++++++++++++ 10 files changed, 3025 insertions(+), 2 deletions(-) create mode 100644 docs/api.clients.zones.rst create mode 100644 hcloud/zones/__init__.py create mode 100644 hcloud/zones/client.py create mode 100644 hcloud/zones/domain.py create mode 100644 tests/unit/zones/__init__.py create mode 100644 tests/unit/zones/conftest.py create mode 100644 tests/unit/zones/test_client.py diff --git a/docs/api.clients.zones.rst b/docs/api.clients.zones.rst new file mode 100644 index 00000000..b8809675 --- /dev/null +++ b/docs/api.clients.zones.rst @@ -0,0 +1,30 @@ +ZonesClient +================== + + +.. autoclass:: hcloud.zones.client.ZonesClient + :members: + +.. autoclass:: hcloud.zones.client.BoundZone + :members: + +.. autoclass:: hcloud.zones.client.BoundZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.Zone + :members: + +.. autoclass:: hcloud.zones.domain.ZoneAuthoritativeNameservers + :members: + +.. autoclass:: hcloud.zones.domain.ZonePrimaryNameserver + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRecord + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.CreateZoneResponse + :members: diff --git a/hcloud/_client.py b/hcloud/_client.py index 0b67a4fe..4c499585 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -26,6 +26,7 @@ from .servers import ServersClient from .ssh_keys import SSHKeysClient from .volumes import VolumesClient +from .zones import ZonesClient class BackoffFunction(Protocol): @@ -254,6 +255,12 @@ def __init__( :type: :class:`PlacementGroupsClient ` """ + self.zones = ZonesClient(self) + """ZonesClient Instance + + :type: :class:`ZonesClient ` + """ + def request( # type: ignore[no-untyped-def] self, method: str, diff --git a/hcloud/zones/__init__.py b/hcloud/zones/__init__.py new file mode 100644 index 00000000..7871e100 --- /dev/null +++ b/hcloud/zones/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from .client import ( + BoundZone, + BoundZoneRRSet, + ZonesClient, + ZonesPageResult, +) +from .domain import ( + CreateZoneResponse, + Zone, + ZoneAuthoritativeNameservers, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, +) + +__all__ = [ + "BoundZone", + "BoundZoneRRSet", + "CreateZoneResponse", + "Zone", + "ZoneAuthoritativeNameservers", + "ZonePrimaryNameserver", + "ZoneRecord", + "ZoneRRSet", + "ZonesClient", + "ZonesPageResult", +] diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py new file mode 100644 index 00000000..09fbd277 --- /dev/null +++ b/hcloud/zones/client.py @@ -0,0 +1,1428 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import ( + CreateZoneResponse, + CreateZoneRRSetResponse, + DeleteZoneResponse, + DeleteZoneRRSetResponse, + ExportZonefileResponse, + Zone, + ZoneAuthoritativeNameservers, + ZoneMode, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZoneRRSetType, +) + +if TYPE_CHECKING: + from .._client import Client + + +class BoundZone(BoundModelBase, Zone): + _client: ZonesClient + + model = Zone + + def __init__( + self, + client: ZonesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("primary_nameservers") + if raw is not None: + data["primary_nameservers"] = [ + ZonePrimaryNameserver.from_dict(o) for o in raw + ] + + raw = data.get("authoritative_nameservers") + if raw: + data["authoritative_nameservers"] = ZoneAuthoritativeNameservers.from_dict( + raw + ) + + super().__init__(client, data, complete) + + def update( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param labels: User-defined labels (key/value pairs) for the Resource. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.update(self, labels=labels) + + def delete(self) -> DeleteZoneResponse: + """ + Deletes the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.delete(self) + + def export_zonefile(self) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.export_zonefile(self) + + def get_actions_list( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zonefile: Zone file to import. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.import_zonefile(self, zonefile=zonefile) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param delete: Prevents the Zone from being deleted. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_protection(self, delete=delete) + + def change_ttl( + self, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param ttl: Default Time To Live (TTL) of the Zone. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_ttl(self, ttl=ttl) + + def change_primary_nameservers( + self, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param primary_nameservers: Primary nameservers of the Zone. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_primary_nameservers( + self, + primary_nameservers=primary_nameservers, + ) + + def get_rrset( + self, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.get_rrset(self, name=name, type=type) + + def get_rrset_list( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.get_rrset_list( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_rrset_all( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.get_rrset_all( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.create_rrset( + self, + name=name, + type=type, + ttl=ttl, + labels=labels, + records=records, + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.update_rrset(rrset, labels=labels) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.delete_rrset(rrset) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_rrset_protection(rrset, change=change) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_rrset_ttl(rrset, ttl=ttl) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.add_rrset_records(rrset, records=records, ttl=ttl) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.remove_rrset_records(rrset, records=records) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.set_rrset_records(rrset, records=records) + + +class BoundZoneRRSet(BoundModelBase, ZoneRRSet): + _client: ZonesClient + + model = ZoneRRSet + + def __init__(self, client: ZonesClient, data: dict, complete: bool = True): + raw = data.get("zone") + if raw is not None: + data["zone"] = BoundZone(client, data={"id": raw}, complete=False) + + raw = data.get("records") + if raw is not None: + data["records"] = [ZoneRecord.from_dict(o) for o in raw] + + super().__init__(client, data, complete) + + def update_rrset( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param labels: User-defined labels (key/value pairs) for the Resource. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.update_rrset(self, labels=labels) + + def delete_rrset( + self, + ) -> DeleteZoneRRSetResponse: + """ + Deletes the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.delete_rrset(self) + + def change_rrset_protection( + self, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param change: Prevent the Zone from being changed (deletion and updates). + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_rrset_protection(self, change=change) + + def change_rrset_ttl( + self, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param change: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.change_rrset_ttl(self, ttl=ttl) + + def add_rrset_records( + self, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.add_rrset_records(self, records=records, ttl=ttl) + + def remove_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param records: Records to remove from the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.remove_rrset_records(self, records=records) + + def set_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param records: Records to set in the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._client.set_rrset_records(self, records=records) + + +class ZonesPageResult(NamedTuple): + zones: list[BoundZone] + meta: Meta + + +class ZoneRRSetsPageResult(NamedTuple): + rrsets: list[BoundZoneRRSet] + meta: Meta + + +class ZonesClient(ResourceClientBase): + """ + ZoneClient is a client for the Zone (DNS) API. + + See https://docs.hetzner.cloud/reference/cloud#zones and https://docs.hetzner.cloud/reference/cloud#zone-rrsets. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + + _base_url = "/zones" + + actions: ResourceActionsClient + """Zones scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get(self, id_or_name: int | str) -> BoundZone: + """ + Returns a single Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-get-a-zone + + :param id_or_name: ID or Name of the Zone. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id_or_name}", + ) + return BoundZone(self, response["zone"]) + + def get_list( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZonesPageResult: + """ + Returns a list of Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if mode is not None: + params["mode"] = mode + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return ZonesPageResult( + zones=[BoundZone(self, item) for item in response["zones"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZone]: + """ + Returns a list of all Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._iter_pages( + self.get_list, + name=name, + mode=mode, + label_selector=label_selector, + sort=sort, + ) + + def create( + self, + *, + name: str, + mode: ZoneMode, + ttl: int | None = None, + labels: dict[str, str] | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + rrsets: list[ZoneRRSet] | None = None, + zonefile: str | None = None, + ) -> CreateZoneResponse: + """ + Creates a Zone. + + A default SOA and three NS resource records with the assigned Hetzner nameservers are created automatically. + + See https://docs.hetzner.cloud/reference/cloud#zones-create-a-zone + + :param name: Name of the Zone. + :param mode: Mode of the Zone. + :param ttl: Default Time To Live (TTL) of the Zone. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param primary_nameservers: Primary nameservers of the Zone. + :param rrsets: RRSets to be added to the Zone. + :param zonefile: Zone file to import. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = { + "name": name, + "mode": mode, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if primary_nameservers is not None: + data["primary_nameservers"] = [o.to_payload() for o in primary_nameservers] + if rrsets is not None: + data["rrsets"] = [o.to_payload() for o in rrsets] + if zonefile is not None: + data["zonefile"] = zonefile + + response = self._client.request( + method="POST", + url=f"{self._base_url}", + json=data, + ) + + return CreateZoneResponse( + zone=BoundZone(self, response["zone"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update( + self, + zone: Zone | BoundZone, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param zone: Zone to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{zone.id_or_name}", + json=data, + ) + return BoundZone(self, response["zone"]) + + def delete( + self, + zone: Zone | BoundZone, + ) -> DeleteZoneResponse: + """ + Deletes a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + + :param zone: Zone to delete. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{zone.id_or_name}", + ) + + return DeleteZoneResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def export_zonefile( + self, + zone: Zone | BoundZone, + ) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + + :param zone: Zone to export the zone file from. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/zonefile", + ) + return ExportZonefileResponse(response["zonefile"]) + + def get_actions_list( + self, + zone: Zone | BoundZone, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for a Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + def get_actions( + self, + zone: Zone | BoundZone, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._iter_pages( + self.get_actions_list, + zone, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zone: Zone | BoundZone, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zone: Zone to import the zone file into. + :param zonefile: Zone file to import. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = { + "zonefile": zonefile, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/import_zonefile", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_protection( + self, + zone: Zone | BoundZone, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param zone: Zone to update. + :param delete: Prevents the Zone from being deleted. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_ttl( + self, + zone: Zone | BoundZone, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param zone: Zone to update. + :param ttl: Default Time To Live (TTL) of the Zone. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_primary_nameservers( + self, + zone: Zone | BoundZone, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param zone: Zone to update. + :param primary_nameservers: Primary nameservers of the Zone. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = { + "primary_nameservers": [o.to_payload() for o in primary_nameservers], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_primary_nameservers", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def get_rrset( + self, + zone: Zone | BoundZone, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param zone: Zone to fetch the RRSet from. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets/{name}/{type}", + ) + return BoundZoneRRSet(self, response["rrset"]) + + def get_rrset_list( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if type is not None: + params["type"] = type + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + params=params, + ) + return ZoneRRSetsPageResult( + rrsets=[BoundZoneRRSet(self, item) for item in response["rrsets"]], + meta=Meta.parse_meta(response), + ) + + def get_rrset_all( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + return self._iter_pages( + self.get_rrset_list, + zone, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + zone: Zone | BoundZone, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param zone: Zone to create the RRSets in. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + data: dict[str, Any] = { + "name": name, + "type": type, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if records is not None: + data["records"] = [o.to_payload() for o in records] + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + json=data, + ) + return CreateZoneRRSetResponse( + rrset=BoundZoneRRSet(self, response["rrset"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + json=data, + ) + return BoundZoneRRSet(self, response["rrset"]) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + ) + return DeleteZoneRRSetResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if change is not None: + data["change"] = change + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + if ttl is not None: + data["ttl"] = ttl + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + + Experimental: + DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/zones/domain.py b/hcloud/zones/domain.py new file mode 100644 index 00000000..cb80ef5c --- /dev/null +++ b/hcloud/zones/domain.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from dateutil.parser import isoparse + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundZone, BoundZoneRRSet + + +ZoneMode = Literal["primary", "secondary"] +ZoneStatus = Literal["ok", "updating", "error"] +ZoneRegistrar = Literal["hetzner", "other", "unknown"] + + +class Zone(BaseDomain, DomainIdentityMixin): + """ + Zone Domain. + + See https://docs.hetzner.cloud/reference/cloud#zones. + """ + + MODE_PRIMARY = "primary" + """ + Zone in primary mode, resource record sets (RRSets) and resource records (RRs) are + managed via the Cloud API or Cloud Console. + """ + MODE_SECONDARY = "secondary" + """ + Zone in secondary mode, Hetzner's nameservers query RRSets and RRs from given + primary nameservers via AXFR. + """ + + STATUS_OK = "ok" + """The Zone is pushed to the authoritative nameservers.""" + STATUS_UPDATING = "updating" + """The Zone is currently being published to the authoritative nameservers.""" + STATUS_ERROR = "error" + """The Zone could not be published to the authoritative nameservers.""" + + REGISTRAR_HETZNER = "hetzner" + REGISTRAR_OTHER = "other" + REGISTRAR_UNKNOWN = "unknown" + + __api_properties__ = ( + "id", + "name", + "created", + "mode", + "ttl", + "labels", + "protection", + "status", + "record_count", + "registrar", + "primary_nameservers", + "authoritative_nameservers", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + created: str | None = None, + mode: ZoneMode | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneProtection | None = None, + status: ZoneStatus | None = None, + record_count: int | None = None, + registrar: ZoneRegistrar | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + authoritative_nameservers: ZoneAuthoritativeNameservers | None = None, + ): + self.id = id + self.name = name + self.created = isoparse(created) if created else None + self.mode = mode + self.ttl = ttl + self.labels = labels + self.protection = protection + self.status = status + self.record_count = record_count + self.registrar = registrar + self.primary_nameservers = primary_nameservers + self.authoritative_nameservers = authoritative_nameservers + + +ZonePrimaryNameserverTSIGAlgorithm = Literal[ + "hmac-md5", + "hmac-sha1", + "hmac-sha256", +] + + +class ZonePrimaryNameserver(BaseDomain): + """ + Zone Primary Nameserver Domain. + """ + + TSIG_ALGORITHM_HMAC_MD5 = "hmac-md5" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA1 = "hmac-sha1" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA256 = "hmac-sha256" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + + __api_properties__ = ( + "address", + "port", + "tsig_algorithm", + "tsig_key", + ) + __slots__ = __api_properties__ + + def __init__( + self, + address: str, + port: int | None = None, + tsig_algorithm: ZonePrimaryNameserverTSIGAlgorithm | None = None, + tsig_key: str | None = None, + ): + self.address = address + self.port = port + self.tsig_algorithm = tsig_algorithm + self.tsig_key = tsig_key + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "address": self.address, + } + if self.port is not None: + payload["port"] = self.port + if self.tsig_algorithm is not None: + payload["tsig_algorithm"] = self.tsig_algorithm + if self.tsig_key is not None: + payload["tsig_key"] = self.tsig_key + + return payload + + +ZoneAuthoritativeNameserversDelegationStatus = Literal[ + "valid", + "partially-valid", + "invalid", + "lame", + "unregistered", + "unknown", +] + + +class ZoneAuthoritativeNameservers(BaseDomain): + """ + Zone Authoritative Nameservers Domain. + """ + + DELEGATION_STATUS_VALID = "valid" + DELEGATION_STATUS_PARTIALLY_VALID = "partially-valid" + DELEGATION_STATUS_INVALID = "invalid" + DELEGATION_STATUS_LAME = "lame" + DELEGATION_STATUS_UNREGISTERED = "unregistered" + DELEGATION_STATUS_UNKNOWN = "unknown" + + __api_properties__ = ( + "assigned", + "delegated", + "delegation_last_check", + "delegation_status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + assigned: list[str] | None = None, + delegated: list[str] | None = None, + delegation_last_check: str | None = None, + delegation_status: ZoneAuthoritativeNameserversDelegationStatus | None = None, + ): + self.assigned = assigned + self.delegated = delegated + self.delegation_last_check = ( + isoparse(delegation_last_check) + if delegation_last_check is not None + else None + ) + self.delegation_status = delegation_status + + +class ZoneProtection(TypedDict): + """ + Zone Protection. + """ + + delete: bool + + +class CreateZoneResponse(BaseDomain): + """ + Create Zone Response Domain. + """ + + __api_properties__ = ("zone", "action") + __slots__ = __api_properties__ + + def __init__( + self, + zone: BoundZone, + action: BoundAction, + ): + self.zone = zone + self.action = action + + +class DeleteZoneResponse(BaseDomain): + """ + Delete Zone Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class ExportZonefileResponse(BaseDomain): + """ + Export Zonefile Response Domain. + """ + + __api_properties__ = ("zonefile",) + __slots__ = __api_properties__ + + def __init__( + self, + zonefile: str, + ): + self.zonefile = zonefile + + +ZoneRRSetType = Literal[ + "A", + "AAAA", + "CAA", + "CNAME", + "DS", + "HINFO", + "HTTPS", + "MX", + "NS", + "PTR", + "RP", + "SOA", + "SRV", + "SVCB", + "TLSA", + "TXT", +] + + +class ZoneRRSet(BaseDomain): + """ + Zone RRSet Domain. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets + """ + + TYPE_A = "A" + TYPE_AAAA = "AAAA" + TYPE_CAA = "CAA" + TYPE_CNAME = "CNAME" + TYPE_DS = "DS" + TYPE_HINFO = "HINFO" + TYPE_HTTPS = "HTTPS" + TYPE_MX = "MX" + TYPE_NS = "NS" + TYPE_PTR = "PTR" + TYPE_RP = "RP" + TYPE_SOA = "SOA" + TYPE_SRV = "SRV" + TYPE_SVCB = "SVCB" + TYPE_TLSA = "TLSA" + TYPE_TXT = "TXT" + + __api_properties__ = ( + "name", + "type", + "ttl", + "labels", + "protection", + "records", + "id", + "zone", + ) + __slots__ = __api_properties__ + + def __init__( + self, + name: str | None = None, + type: ZoneRRSetType | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneRRSetProtection | None = None, + records: list[ZoneRecord] | None = None, + id: str | None = None, + zone: BoundZone | Zone | None = None, + ): + # Ensure that 'id', 'name' and 'type' are always populated. + if name is not None and type is not None: + if id is None: + id = f"{name}/{type}" + else: + if id is not None: + name, _, type = id.partition("/") # type: ignore[assignment] + else: + raise ValueError("id or name and type must be set") + + self.name = name + self.type = type + self.ttl = ttl + self.labels = labels + self.protection = protection + self.records = records + + self.id = id + self.zone = zone + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "name": self.name, + "type": self.type, + } + if self.ttl is not None: + payload["ttl"] = self.ttl + if self.labels is not None: + payload["labels"] = self.labels + if self.protection is not None: + payload["protection"] = self.protection + if self.records is not None: + payload["records"] = [o.to_payload() for o in self.records] + + return payload + + +class ZoneRRSetProtection(TypedDict): + """ + Zone RRSet Protection. + """ + + change: bool + + +class ZoneRecord(BaseDomain): + """ + Zone Record Domain. + """ + + __api_properties__ = ( + "value", + "comment", + ) + __slots__ = __api_properties__ + + def __init__( + self, + value: str, + comment: str | None = None, + ): + self.value = value + self.comment = comment + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "value": self.value, + } + if self.comment is not None: + payload["comment"] = self.comment + + return payload + + +class CreateZoneRRSetResponse(BaseDomain): + """ + Create Zone RRSet Response Domain. + """ + + __api_properties__ = ( + "rrset", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + rrset: BoundZoneRRSet, + action: BoundAction, + ): + self.rrset = rrset + self.action = action + + +class DeleteZoneRRSetResponse(BaseDomain): + """ + Delete Zone RRSet Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index e3c266b2..6c09f7dd 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -23,6 +23,7 @@ from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient from hcloud.servers import BoundServer, ServersClient from hcloud.volumes import BoundVolume, VolumesClient +from hcloud.zones import BoundZone, ZonesClient from ..conftest import assert_bound_action1, assert_bound_action2 @@ -36,6 +37,7 @@ "primary_ips": (PrimaryIPsClient, BoundPrimaryIP), "servers": (ServersClient, BoundServer), "volumes": (VolumesClient, BoundVolume), + "zones": (ZonesClient, BoundZone), } @@ -241,6 +243,12 @@ def resource(self, request): def resource_client(self, client: Client, resource: str) -> ResourceClientBase: return getattr(client, resource) + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> BoundModelBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) + @pytest.mark.parametrize( "params", [ @@ -253,12 +261,13 @@ def test_get_actions_list( request_mock: mock.MagicMock, resource_client: ResourceClientBase, resource: str, + bound_model: BoundModelBase, action_list_response, params, ): request_mock.return_value = action_list_response - result = resource_client.get_actions_list(mock.MagicMock(id=1), **params) + result = resource_client.get_actions_list(bound_model, **params) request_mock.assert_called_with( method="GET", @@ -285,12 +294,13 @@ def test_get_actions( request_mock: mock.MagicMock, resource_client: ResourceClientBase, resource: str, + bound_model: BoundModelBase, action_list_response, params, ): request_mock.return_value = action_list_response - actions = resource_client.get_actions(mock.MagicMock(id=1), **params) + actions = resource_client.get_actions(bound_model, **params) request_mock.assert_called_with( method="GET", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1e9a75b3..a340a6e3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Generator from unittest import mock +from warnings import warn import pytest @@ -130,3 +132,13 @@ def action_list_response(action1_running, action2_running): action2_running, ], } + + +@pytest.fixture() +def hetzner_client() -> Generator[Client]: + warn("DEPRECATED") + client = Client(token="token") + patcher = mock.patch.object(client, "request") + patcher.start() + yield client + patcher.stop() diff --git a/tests/unit/zones/__init__.py b/tests/unit/zones/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/zones/conftest.py b/tests/unit/zones/conftest.py new file mode 100644 index 00000000..4984cf3b --- /dev/null +++ b/tests/unit/zones/conftest.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def zone1(): + return { + "id": 42, + "name": "example1.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "primary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone2(): + return { + "id": 43, + "name": "example2.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "secondary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone_rrset1(): + return { + "zone": 42, + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_rrset2(): + return { + "zone": 42, + "id": "blog/A", + "name": "blog", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_response(zone1): + return {"zone": zone1} + + +@pytest.fixture() +def zone_list_response(zone1, zone2): + return { + "zones": [zone1, zone2], + } + + +@pytest.fixture() +def zone_create_response(zone1, action1_running): + return { + "zone": zone1, + "action": action1_running, + } + + +@pytest.fixture() +def zone_rrset_response(zone_rrset1): + return { + "rrset": zone_rrset1, + } + + +@pytest.fixture() +def zone_rrset_list_response(zone_rrset1, zone_rrset2): + return { + "rrsets": [zone_rrset1, zone_rrset2], + } + + +@pytest.fixture() +def zone_rrset_create_response(zone_rrset1, action1_running): + return { + "rrset": zone_rrset1, + "action": action1_running, + } diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py new file mode 100644 index 00000000..91165ad1 --- /dev/null +++ b/tests/unit/zones/test_client.py @@ -0,0 +1,928 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.zones import ( + BoundZone, + BoundZoneRRSet, + Zone, + ZoneAuthoritativeNameservers, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZonesClient, +) + +from ..conftest import assert_bound_action1 + + +def assert_bound_zone1(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 42 + assert o.name == "example1.com" + + +def assert_bound_zone2(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 43 + assert o.name == "example2.com" + + +def assert_bound_zone_rrset1(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "www" + assert o.type == "A" + assert o.id == "www/A" + + +def assert_bound_zone_rrset2(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "blog" + assert o.type == "A" + assert o.id == "blog/A" + + +class TestZonesClient: + @pytest.fixture() + def resource_client(self, client: Client) -> ZonesClient: + return client.zones + + def test_get_using_id( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get(42) + + request_mock.assert_called_with( + method="GET", + url="/zones/42", + ) + + assert_bound_zone1(result, resource_client) + assert result.created == isoparse("2016-01-30T23:55:00+00:00") + assert result.mode == "primary" + assert result.ttl == 10800 + assert result.protection == {"delete": False} + assert result.labels == {"key": "value"} + assert result.primary_nameservers[0].address == "198.51.100.1" + assert result.primary_nameservers[0].port == 53 + assert result.primary_nameservers[1].address == "203.0.113.1" + assert result.primary_nameservers[1].port == 53 + + assert ( + result.authoritative_nameservers.assigned[0] == "hydrogen.ns.hetzner.com." + ) + assert ( + result.authoritative_nameservers.delegated[0] == "hydrogen.ns.hetzner.com." + ) + assert result.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert result.authoritative_nameservers.delegation_status == "valid" + + assert result.record_count == 0 + assert result.status == "ok" + assert result.registrar == "hetzner" + + def test_get_using_name( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get("example.com") + + request_mock.assert_called_with( + method="GET", + url="/zones/example.com", + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"mode": "primary"}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "example.com"}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + resp = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.zones) == 2 + assert_bound_zone1(resp.zones[0], resource_client) + assert_bound_zone2(resp.zones[1], resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone1(result[0], resource_client) + assert_bound_zone2(result[1], resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_create_response, + ): + request_mock.return_value = zone_create_response + + resp = resource_client.create( + name="example.com", + mode="primary", + ttl=3600, + labels={"key": "value"}, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + rrsets=[ZoneRRSet(name="www", type="A", records=[ZoneRecord("127.0.0.1")])], + ) + + request_mock.assert_called_with( + url="/zones", + method="POST", + json={ + "name": "example.com", + "mode": "primary", + "ttl": 3600, + "labels": {"key": "value"}, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ], + "rrsets": [ + {"name": "www", "type": "A", "records": [{"value": "127.0.0.1"}]} + ], + }, + ) + + assert_bound_zone1(resp.zone, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.update(zone, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + resp = resource_client.delete(zone) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_export_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + ): + request_mock.return_value = {"zonefile": "content"} + + resp = resource_client.export_zonefile(zone) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/zonefile", + ) + + assert resp.zonefile == "content" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_import_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.import_zonefile(zone, "content") + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/import_zonefile", + json={"zonefile": "content"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(zone, delete=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_primary_nameservers( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_primary_nameservers( + zone, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_primary_nameservers", + json={ + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_ttl(zone, 3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + # ============ RRSETS ============ + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_get_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_response, + ): + request_mock.return_value = zone_rrset_response + + result = resource_client.get_rrset(zone, "www", "A") + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_zone_rrset1(result, resource_client) + assert result.ttl == 3600 + assert result.protection == {"change": False} + assert result.labels == {"key": "value"} + assert result.records[0].value == "198.51.100.1" + assert result.records[0].comment == "web server" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"type": ["A"]}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "www"}, + {}, + ], + ) + def test_get_rrset_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + resp = resource_client.get_rrset_list(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.rrsets) == 2 + assert_bound_zone_rrset1(resp.rrsets[0], resource_client) + assert_bound_zone_rrset2(resp.rrsets[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_rrset_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + result = resource_client.get_rrset_all(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone_rrset1(result[0], resource_client) + assert_bound_zone_rrset2(result[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_create_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_create_response, + ): + request_mock.return_value = zone_rrset_create_response + + resp = resource_client.create_rrset( + zone, + name="www", + type="A", + ttl=3600, + labels={"key": "value"}, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets", + json={ + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + }, + ) + + assert_bound_zone_rrset1(resp.rrset, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_update_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + zone_rrset_response, + ): + rrset.zone = zone + + request_mock.return_value = zone_rrset_response + + result = resource_client.update_rrset(rrset, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone_rrset1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_delete_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + resp = resource_client.delete_rrset(rrset) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_protection(rrset, change=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json={"change": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_ttl(rrset, ttl=3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_add_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.add_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ttl=300, + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + "ttl": 300, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_remove_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.remove_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_set_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.set_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + +class TestBoundZone: + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client, zone1): + return BoundZone(resource_client, data=zone1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZone): + o = bound_model + + assert_bound_zone1(o, resource_client) + + assert o.id == 42 + assert o.name == "example1.com" + assert o.created == isoparse("2016-01-30T23:55:00+00:00") + assert o.mode == "primary" + assert o.ttl == 10800 + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert len(o.primary_nameservers) == 2 + + assert isinstance(o.primary_nameservers[0], ZonePrimaryNameserver) + assert o.primary_nameservers[0].address == "198.51.100.1" + assert o.primary_nameservers[0].port == 53 + assert isinstance(o.primary_nameservers[1], ZonePrimaryNameserver) + assert o.primary_nameservers[1].address == "203.0.113.1" + assert o.primary_nameservers[1].port == 53 + + assert isinstance(o.authoritative_nameservers, ZoneAuthoritativeNameservers) + assert o.authoritative_nameservers.assigned == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegated == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert o.authoritative_nameservers.delegation_status == "valid" + + assert o.record_count == 0 + assert o.status == "ok" + assert o.registrar == "hetzner" + + +class TestBoundZoneRRSet: + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client, zone_rrset1): + return BoundZoneRRSet(resource_client, data=zone_rrset1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZoneRRSet): + o = bound_model + + assert_bound_zone_rrset1(o, resource_client) + + assert o.id == "www/A" + assert o.name == "www" + assert o.type == "A" + assert o.ttl == 3600 + assert o.labels == {"key": "value"} + assert o.protection == {"change": False} + assert len(o.records) == 1 + + assert isinstance(o.records[0], ZoneRecord) + assert o.records[0].value == "198.51.100.1" + assert o.records[0].comment == "web server" + + assert isinstance(o.zone, BoundZone) + assert o.zone.id == 42 From 36670bd52ea3474efd3377d49b5300775607a6dd Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:19:29 +0200 Subject: [PATCH 110/213] chore(main): release v2.8.0 (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### DNS API Beta This release adds support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta) for more details. Future minor releases of this project may include breaking changes for features that are related to the DNS API. See the [DNS API Beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. **Examples** ```py resp = client.zones.create( name="example.com", mode="primary", labels={"key": "value"}, rrsets=[ ZoneRRSet( name="@", type="A", records=[ ZoneRecord(value="201.180.75.2", comment="server1") ], ) ], ) resp.action.wait_until_finished() zone = resp.zone ``` ### Features - add new `ip_range` param to load balancer `attach_to_network` (#562) - add new `ip_range` param to server `attach_to_network` (#561) - support the new DNS API (#568) ### Bug Fixes - source_ips property is optional in firewall rule (#567) ---

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 ### DNS API Beta This release adds support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta) for more details. Future minor releases of this project may include breaking changes for features that are related to the DNS API. See the [DNS API Beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. **Examples** ```py resp = client.zones.create( name="example.com", mode="primary", labels={"key": "value"}, rrsets=[ ZoneRRSet( name="@", type="A", records=[ ZoneRecord(value="201.180.75.2", comment="server1") ], ) ], ) resp.action.wait_until_finished() zone = resp.zone ``` ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be18ccdc..418422f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## [v2.8.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.8.0) + +### DNS API Beta + +This release adds support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). + +The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta) for more details. + +Future minor releases of this project may include breaking changes for features that are related to the DNS API. + +See the [DNS API Beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. + +**Examples** + +```py +resp = client.zones.create( + name="example.com", + mode="primary", + labels={"key": "value"}, + rrsets=[ + ZoneRRSet( + name="@", + type="A", + records=[ + ZoneRecord(value="201.180.75.2", comment="server1") + ], + ) + ], +) + +resp.action.wait_until_finished() +zone = resp.zone +``` + +### Features + +- add new `ip_range` param to load balancer `attach_to_network` (#562) +- add new `ip_range` param to server `attach_to_network` (#561) +- support the new DNS API (#568) + +### Bug Fixes + +- source_ips property is optional in firewall rule (#567) + ## [v2.7.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.7.0) [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). diff --git a/hcloud/_version.py b/hcloud/_version.py index c99e8018..e9a32abd 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.7.0" # x-releaser-pleaser-version +__version__ = "2.8.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 6f75899c..9a9bcdd1 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.7.0", # x-releaser-pleaser-version + version="2.8.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From c7fa18e797ce44febdc8364a1dfdfacbce6f2a8a Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 8 Oct 2025 09:45:25 +0200 Subject: [PATCH 111/213] feat: support python 3.14 (#566) Adds support for the newly released Python 3.14. --- .github/workflows/test.yml | 8 +++++++- .gitlab-ci.yml | 8 +++++++- setup.py | 1 + tox.ini | 3 ++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14899fa4..2eee2995 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,13 @@ jobs: strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" name: Python ${{ matrix.python-version }} steps: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4cef0733..db175f38 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,7 +26,13 @@ test: parallel: matrix: - - python_version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + - python_version: + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" image: python:${python_version}-alpine before_script: diff --git a/setup.py b/setup.py index 9a9bcdd1..53f75dde 100644 --- a/setup.py +++ b/setup.py @@ -32,6 +32,7 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], python_requires=">=3.9", install_requires=[ diff --git a/tox.ini b/tox.ini index 5e316e4e..1baff836 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39, py310, py311, py312, py313 +envlist = py39, py310, py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT @@ -15,3 +15,4 @@ python = 3.11: py311 3.12: py312 3.13: py313 + 3.14: py314 From 131b142a892fb14e5fcc3d178b9bea5e390f80da Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 09:45:39 +0200 Subject: [PATCH 112/213] chore(deps): update python docker tag to v3.14 (#569) 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 | |---|---|---|---| | python | image | minor | `3.13-alpine` -> `3.14-alpine` | --- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index db175f38..c6236900 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ pre-commit: lint: stage: test - image: python:3.13-alpine + image: python:3.14-alpine before_script: - apk add make bash - make venv From 14675849691f5e2be8d07c5c23475b0072ec5357 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 01:38:51 +0000 Subject: [PATCH 113/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.21.0 (#571) 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 | |---|---|---|---| | [asottile/pyupgrade](https://redirect.github.com/asottile/pyupgrade) | repository | minor | `v3.20.0` -> `v3.21.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
asottile/pyupgrade (asottile/pyupgrade) ### [`v3.21.0`](https://redirect.github.com/asottile/pyupgrade/compare/v3.20.0...v3.21.0) [Compare Source](https://redirect.github.com/asottile/pyupgrade/compare/v3.20.0...v3.21.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 8bc9e73d..c3c10abd 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.20.0 + rev: v3.21.0 hooks: - id: pyupgrade args: [--py39-plus] From ab6a6577f54a7037bacb8cc13b06558379c2d2a2 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 10 Oct 2025 09:50:09 +0200 Subject: [PATCH 114/213] test: generate bound model methods tests (#546) In our bound models, many of the methods are only proxies to the api client methods. But our tests are always testing the full stack down to the HTTP requests. This lead to a lot of duplicate tests for the API client and the bound models methods. This change removes the bound models methods tests, and uses a generic "proxy method test" for all our bound models. --- hcloud/zones/client.py | 14 +- tests/unit/certificates/test_client.py | 106 +-- tests/unit/conftest.py | 109 ++- tests/unit/firewalls/test_client.py | 189 ++--- tests/unit/floating_ips/test_client.py | 179 +---- tests/unit/images/test_client.py | 81 +-- tests/unit/load_balancers/test_client.py | 620 +++++++++------- tests/unit/networks/test_client.py | 187 +---- tests/unit/placement_groups/test_client.py | 143 ++-- tests/unit/primary_ips/test_client.py | 131 +--- tests/unit/servers/test_client.py | 790 +++++---------------- tests/unit/ssh_keys/test_client.py | 55 +- tests/unit/volumes/test_client.py | 165 +---- tests/unit/zones/test_client.py | 42 +- 14 files changed, 992 insertions(+), 1819 deletions(-) diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index 09fbd277..baa841be 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -352,7 +352,7 @@ def update_rrset( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.update_rrset(rrset, labels=labels) + return self._client.update_rrset(rrset=rrset, labels=labels) def delete_rrset( self, @@ -369,7 +369,7 @@ def delete_rrset( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.delete_rrset(rrset) + return self._client.delete_rrset(rrset=rrset) def change_rrset_protection( self, @@ -389,7 +389,7 @@ def change_rrset_protection( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.change_rrset_protection(rrset, change=change) + return self._client.change_rrset_protection(rrset=rrset, change=change) def change_rrset_ttl( self, @@ -408,7 +408,7 @@ def change_rrset_ttl( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.change_rrset_ttl(rrset, ttl=ttl) + return self._client.change_rrset_ttl(rrset=rrset, ttl=ttl) def add_rrset_records( self, @@ -429,7 +429,7 @@ def add_rrset_records( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.add_rrset_records(rrset, records=records, ttl=ttl) + return self._client.add_rrset_records(rrset=rrset, records=records, ttl=ttl) def remove_rrset_records( self, @@ -448,7 +448,7 @@ def remove_rrset_records( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.remove_rrset_records(rrset, records=records) + return self._client.remove_rrset_records(rrset=rrset, records=records) def set_rrset_records( self, @@ -467,7 +467,7 @@ def set_rrset_records( DNS API is in beta, breaking changes may occur within minor releases. See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ - return self._client.set_rrset_records(rrset, records=records) + return self._client.set_rrset_records(rrset=rrset, records=records) class BoundZoneRRSet(BoundModelBase, ZoneRRSet): diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index cd1c8a60..8182bb48 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -12,88 +12,42 @@ ManagedCertificateStatus, ) +from ..conftest import BoundModelTestCase -class TestBoundCertificate: - @pytest.fixture() - def bound_certificate(self, client: Client): - return BoundCertificate(client.certificates, data=dict(id=14)) - - def test_bound_certificate_init(self, certificate_response): - bound_certificate = BoundCertificate( - client=mock.MagicMock(), data=certificate_response["certificate"] - ) - - assert bound_certificate.id == 2323 - assert bound_certificate.name == "My Certificate" - assert bound_certificate.type == "managed" - assert ( - bound_certificate.fingerprint - == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" - ) - assert bound_certificate.certificate == "-----BEGIN CERTIFICATE-----\n..." - assert len(bound_certificate.domain_names) == 3 - assert bound_certificate.domain_names[0] == "example.com" - assert bound_certificate.domain_names[1] == "webmail.example.com" - assert bound_certificate.domain_names[2] == "www.example.com" - assert isinstance(bound_certificate.status, ManagedCertificateStatus) - assert bound_certificate.status.issuance == "failed" - assert bound_certificate.status.renewal == "scheduled" - assert bound_certificate.status.error.code == "error_code" - assert bound_certificate.status.error.message == "error message" - - def test_update( - self, - request_mock: mock.MagicMock, - bound_certificate, - response_update_certificate, - ): - request_mock.return_value = response_update_certificate - certificate = bound_certificate.update(name="New name") +class TestBoundCertificate(BoundModelTestCase): + methods = [ + BoundCertificate.update, + BoundCertificate.delete, + BoundCertificate.retry_issuance, + ] - request_mock.assert_called_with( - method="PUT", - url="/certificates/14", - json={"name": "New name"}, - ) - - assert certificate.id == 2323 - assert certificate.name == "New name" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_certificate, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_certificate.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/certificates/14", - ) - - assert delete_success is True - - def test_retry_issuance( - self, - request_mock: mock.MagicMock, - bound_certificate, - response_retry_issuance_action, - ): - request_mock.return_value = response_retry_issuance_action - - action = bound_certificate.retry_issuance() + @pytest.fixture() + def resource_client(self, client: Client): + return client.certificates - request_mock.assert_called_with( - method="POST", - url="/certificates/14/actions/retry", + @pytest.fixture() + def bound_model(self, resource_client, certificate_response): + return BoundCertificate( + resource_client, data=certificate_response["certificate"] ) - assert action.id == 14 - assert action.command == "issue_certificate" + def test_init(self, bound_model: BoundCertificate): + o = bound_model + assert o.id == 2323 + assert o.name == "My Certificate" + assert o.type == "managed" + assert o.fingerprint == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + assert o.certificate == "-----BEGIN CERTIFICATE-----\n..." + assert len(o.domain_names) == 3 + assert o.domain_names[0] == "example.com" + assert o.domain_names[1] == "webmail.example.com" + assert o.domain_names[2] == "www.example.com" + assert isinstance(o.status, ManagedCertificateStatus) + assert o.status.issuance == "failed" + assert o.status.renewal == "scheduled" + assert o.status.error.code == "error_code" + assert o.status.error.message == "error message" class TestCertificatesClient: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index a340a6e3..4b83b01a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -2,9 +2,9 @@ from __future__ import annotations -from collections.abc import Generator +import inspect +from typing import Callable, ClassVar, TypedDict from unittest import mock -from warnings import warn import pytest @@ -134,11 +134,100 @@ def action_list_response(action1_running, action2_running): } -@pytest.fixture() -def hetzner_client() -> Generator[Client]: - warn("DEPRECATED") - client = Client(token="token") - patcher = mock.patch.object(client, "request") - patcher.start() - yield client - patcher.stop() +def build_kwargs_mock(func: Callable) -> dict[str, mock.Mock]: + """ + Generate a kwargs dict that may be passed to the provided function for testing purposes. + """ + s = inspect.signature(func) + + kwargs = {} + for name, param in s.parameters.items(): + if name in ("self",): + continue + + if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY): + kwargs[name] = mock.Mock() + continue + + # Ignore **kwargs + if param.kind in (param.VAR_KEYWORD,): + continue + + raise NotImplementedError(f"unsupported parameter kind: {param.kind}") + + return kwargs + + +def pytest_generate_tests(metafunc: pytest.Metafunc): + """ + Magic function to generate a test for each bound model method. + """ + if "bound_model_method" in metafunc.fixturenames: + metafunc.parametrize("bound_model_method", metafunc.cls.methods) + + +class BoundModelTestOptions(TypedDict): + sub_resource: bool + + +class BoundModelTestCase: + methods: ClassVar[list[Callable | tuple[Callable, BoundModelTestOptions]]] + + def test_method_list(self, bound_model): + """ + Ensure the list of bound model methods is up to date. + """ + # Unpack methods + methods = [m[0] if isinstance(m, tuple) else m for m in self.__class__.methods] + + members_count = 0 + members_missing = [] + for name, member in inspect.getmembers( + bound_model, + lambda m: inspect.ismethod(m) + and m.__func__ in bound_model.__class__.__dict__.values(), + ): + # Actions methods are already tested in TestBoundModelActions. + if name in ("__init__", "get_actions", "get_actions_list"): + continue + + if member.__func__ in methods: + members_count += 1 + else: + members_missing.append(member.__func__.__qualname__) + + assert not members_missing, "untested methods:\n" + ",\n".join(members_missing) + assert members_count == len(self.__class__.methods) + + def test_method( + self, + resource_client, + bound_model, + bound_model_method: Callable | tuple[Callable, BoundModelTestOptions], + ): + options = BoundModelTestOptions() + if isinstance(bound_model_method, tuple): + bound_model_method, options = bound_model_method + + # Check if the resource client has a method named after the bound model method. + assert hasattr(resource_client, bound_model_method.__name__) + + # Mock the resource client method. + resource_client_method_mock = mock.MagicMock() + setattr( + resource_client, + bound_model_method.__name__, + resource_client_method_mock, + ) + + kwargs = build_kwargs_mock(bound_model_method) + + # Call the bound model method + result = getattr(bound_model, bound_model_method.__name__)(**kwargs) + + if options.get("sub_resource"): + resource_client_method_mock.assert_called_with(**kwargs) + else: + resource_client_method_mock.assert_called_with(bound_model, **kwargs) + + assert result is resource_client_method_mock.return_value diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index 78b66409..79313c15 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -15,31 +15,42 @@ ) from hcloud.servers import Server +from ..conftest import BoundModelTestCase -class TestBoundFirewall: - @pytest.fixture() - def bound_firewall(self, client: Client): - return BoundFirewall(client.firewalls, data=dict(id=1)) - - def test_bound_firewall_init(self, firewall_response): - bound_firewall = BoundFirewall( - client=mock.MagicMock(), data=firewall_response["firewall"] - ) - assert bound_firewall.id == 38 - assert bound_firewall.name == "Corporate Intranet Protection" - assert bound_firewall.labels == {} - assert isinstance(bound_firewall.rules, list) - assert len(bound_firewall.rules) == 2 +class TestBoundFirewall(BoundModelTestCase): + methods = [ + BoundFirewall.update, + BoundFirewall.delete, + BoundFirewall.apply_to_resources, + BoundFirewall.remove_from_resources, + BoundFirewall.set_rules, + ] - assert isinstance(bound_firewall.applied_to, list) - assert len(bound_firewall.applied_to) == 2 - assert bound_firewall.applied_to[0].server.id == 42 - assert bound_firewall.applied_to[0].type == "server" - assert bound_firewall.applied_to[1].label_selector.selector == "key==value" - assert bound_firewall.applied_to[1].type == "label_selector" + @pytest.fixture() + def resource_client(self, client: Client): + return client.firewalls - firewall_in_rule = bound_firewall.rules[0] + @pytest.fixture() + def bound_model(self, resource_client, firewall_response): + return BoundFirewall(resource_client, data=firewall_response["firewall"]) + + def test_init(self, bound_model: BoundFirewall): + o = bound_model + assert o.id == 38 + assert o.name == "Corporate Intranet Protection" + assert o.labels == {} + assert isinstance(o.rules, list) + assert len(o.rules) == 2 + + assert isinstance(o.applied_to, list) + assert len(o.applied_to) == 2 + assert o.applied_to[0].server.id == 42 + assert o.applied_to[0].type == "server" + assert o.applied_to[1].label_selector.selector == "key==value" + assert o.applied_to[1].type == "label_selector" + + firewall_in_rule = o.rules[0] assert isinstance(firewall_in_rule, FirewallRule) assert firewall_in_rule.direction == FirewallRule.DIRECTION_IN assert firewall_in_rule.protocol == FirewallRule.PROTOCOL_TCP @@ -55,7 +66,7 @@ def test_bound_firewall_init(self, firewall_response): assert len(firewall_in_rule.destination_ips) == 0 assert firewall_in_rule.description == "allow http in" - firewall_out_rule = bound_firewall.rules[1] + firewall_out_rule = o.rules[1] assert isinstance(firewall_out_rule, FirewallRule) assert firewall_out_rule.direction == FirewallRule.DIRECTION_OUT assert firewall_out_rule.protocol == FirewallRule.PROTOCOL_TCP @@ -71,120 +82,6 @@ def test_bound_firewall_init(self, firewall_response): ] assert firewall_out_rule.description == "allow http out" - def test_update( - self, - request_mock: mock.MagicMock, - bound_firewall, - response_update_firewall, - ): - request_mock.return_value = response_update_firewall - - firewall = bound_firewall.update( - name="New Corporate Intranet Protection", labels={} - ) - - request_mock.assert_called_with( - method="PUT", - url="/firewalls/1", - json={"name": "New Corporate Intranet Protection", "labels": {}}, - ) - - assert firewall.id == 38 - assert firewall.name == "New Corporate Intranet Protection" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_firewall, - ): - delete_success = bound_firewall.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/firewalls/1", - ) - - assert delete_success is True - - def test_set_rules( - self, - request_mock: mock.MagicMock, - bound_firewall, - response_set_rules, - ): - request_mock.return_value = response_set_rules - - actions = bound_firewall.set_rules( - [ - FirewallRule( - direction=FirewallRule.DIRECTION_IN, - protocol=FirewallRule.PROTOCOL_ICMP, - source_ips=["0.0.0.0/0", "::/0"], - description="New firewall description", - ) - ] - ) - - request_mock.assert_called_with( - method="POST", - url="/firewalls/1/actions/set_rules", - json={ - "rules": [ - { - "direction": "in", - "protocol": "icmp", - "source_ips": ["0.0.0.0/0", "::/0"], - "description": "New firewall description", - } - ] - }, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - - def test_apply_to_resources( - self, - request_mock: mock.MagicMock, - bound_firewall, - response_set_rules, - ): - request_mock.return_value = response_set_rules - - actions = bound_firewall.apply_to_resources( - [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] - ) - - request_mock.assert_called_with( - method="POST", - url="/firewalls/1/actions/apply_to_resources", - json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - - def test_remove_from_resources( - self, - request_mock: mock.MagicMock, - bound_firewall, - response_set_rules, - ): - request_mock.return_value = response_set_rules - - actions = bound_firewall.remove_from_resources( - [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] - ) - - request_mock.assert_called_with( - method="POST", - url="/firewalls/1/actions/remove_from_resources", - json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - class TestFirewallsClient: @pytest.fixture() @@ -421,7 +318,15 @@ def test_set_rules( direction=FirewallRule.DIRECTION_IN, protocol=FirewallRule.PROTOCOL_ICMP, source_ips=["0.0.0.0/0", "::/0"], - ) + description="Allow ICMP from everywhere", + ), + FirewallRule( + direction=FirewallRule.DIRECTION_IN, + protocol=FirewallRule.PROTOCOL_TCP, + port="80", + source_ips=["0.0.0.0/0", "::/0"], + description="Allow HTTP from everywhere", + ), ], ) @@ -434,7 +339,15 @@ def test_set_rules( "direction": "in", "protocol": "icmp", "source_ips": ["0.0.0.0/0", "::/0"], - } + "description": "Allow ICMP from everywhere", + }, + { + "direction": "in", + "protocol": "tcp", + "port": "80", + "source_ips": ["0.0.0.0/0", "::/0"], + "description": "Allow HTTP from everywhere", + }, ] }, ) diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index acd2b175..6bc991b9 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -9,152 +9,51 @@ from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server +from ..conftest import BoundModelTestCase -class TestBoundFloatingIP: - @pytest.fixture() - def bound_floating_ip(self, client: Client): - return BoundFloatingIP(client.floating_ips, data=dict(id=14)) - - def test_bound_floating_ip_init(self, floating_ip_response): - bound_floating_ip = BoundFloatingIP( - client=mock.MagicMock(), data=floating_ip_response["floating_ip"] - ) - - assert bound_floating_ip.id == 4711 - assert bound_floating_ip.description == "Web Frontend" - assert bound_floating_ip.name == "Web Frontend" - assert bound_floating_ip.ip == "131.232.99.1" - assert bound_floating_ip.type == "ipv4" - assert bound_floating_ip.protection == {"delete": False} - assert bound_floating_ip.labels == {} - assert bound_floating_ip.blocked is False - - assert isinstance(bound_floating_ip.server, BoundServer) - assert bound_floating_ip.server.id == 42 - - assert isinstance(bound_floating_ip.home_location, BoundLocation) - assert bound_floating_ip.home_location.id == 1 - assert bound_floating_ip.home_location.name == "fsn1" - assert bound_floating_ip.home_location.description == "Falkenstein DC Park 1" - assert bound_floating_ip.home_location.country == "DE" - assert bound_floating_ip.home_location.city == "Falkenstein" - assert bound_floating_ip.home_location.latitude == 50.47612 - assert bound_floating_ip.home_location.longitude == 12.370071 - - def test_update( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - response_update_floating_ip, - ): - request_mock.return_value = response_update_floating_ip - - floating_ip = bound_floating_ip.update( - description="New description", name="New name" - ) - - request_mock.assert_called_with( - method="PUT", - url="/floating_ips/14", - json={"description": "New description", "name": "New name"}, - ) - - assert floating_ip.id == 4711 - assert floating_ip.description == "New description" - assert floating_ip.name == "New name" - def test_delete( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_floating_ip.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/floating_ips/14", - ) - - assert delete_success is True +class TestBoundFloatingIP(BoundModelTestCase): + methods = [ + BoundFloatingIP.update, + BoundFloatingIP.delete, + BoundFloatingIP.change_protection, + BoundFloatingIP.change_dns_ptr, + BoundFloatingIP.assign, + BoundFloatingIP.unassign, + ] - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_floating_ip.change_protection(True) - - request_mock.assert_called_with( - method="POST", - url="/floating_ips/14/actions/change_protection", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_assign( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_floating_ip.assign(server) - - request_mock.assert_called_with( - method="POST", - url="/floating_ips/14/actions/assign", - json={"server": 1}, - ) - assert action.id == 1 - assert action.progress == 0 - - def test_unassign( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_floating_ip.unassign() + @pytest.fixture() + def resource_client(self, client: Client): + return client.floating_ips - request_mock.assert_called_with( - method="POST", - url="/floating_ips/14/actions/unassign", + @pytest.fixture() + def bound_model(self, resource_client, floating_ip_response): + return BoundFloatingIP( + resource_client, data=floating_ip_response["floating_ip"] ) - assert action.id == 1 - assert action.progress == 0 - - def test_change_dns_ptr( - self, - request_mock: mock.MagicMock, - bound_floating_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - request_mock.assert_called_with( - method="POST", - url="/floating_ips/14/actions/change_dns_ptr", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, - ) - assert action.id == 1 - assert action.progress == 0 + def test_init(self, bound_model: BoundFloatingIP): + o = bound_model + assert o.id == 4711 + assert o.description == "Web Frontend" + assert o.name == "Web Frontend" + assert o.ip == "131.232.99.1" + assert o.type == "ipv4" + assert o.protection == {"delete": False} + assert o.labels == {} + assert o.blocked is False + + assert isinstance(o.server, BoundServer) + assert o.server.id == 42 + + assert isinstance(o.home_location, BoundLocation) + assert o.home_location.id == 1 + assert o.home_location.name == "fsn1" + assert o.home_location.description == "Falkenstein DC Park 1" + assert o.home_location.country == "DE" + assert o.home_location.city == "Falkenstein" + assert o.home_location.latitude == 50.47612 + assert o.home_location.longitude == 12.370071 class TestFloatingIPsClient: diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index bc927983..d88b36bf 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -10,13 +10,25 @@ from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer +from ..conftest import BoundModelTestCase + + +class TestBoundImage(BoundModelTestCase): + methods = [ + BoundImage.update, + BoundImage.delete, + BoundImage.change_protection, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.images -class TestBoundImage: @pytest.fixture() - def bound_image(self, client: Client): - return BoundImage(client.images, data=dict(id=14)) + def bound_model(self, resource_client): + return BoundImage(resource_client, data=dict(id=14)) - def test_bound_image_init(self, image_response): + def test_init(self, image_response): bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) assert bound_image.id == 4711 @@ -46,67 +58,6 @@ def test_bound_image_init(self, image_response): assert bound_image.bound_to.id == 1 assert bound_image.bound_to.complete is False - def test_update( - self, - request_mock: mock.MagicMock, - bound_image, - response_update_image, - ): - request_mock.return_value = response_update_image - - image = bound_image.update( - description="My new Image description", type="snapshot", labels={} - ) - - request_mock.assert_called_with( - method="PUT", - url="/images/14", - json={ - "description": "My new Image description", - "type": "snapshot", - "labels": {}, - }, - ) - - assert image.id == 4711 - assert image.description == "My new Image description" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_image, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_image.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/images/14", - ) - - assert delete_success is True - - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_image, - action_response, - ): - request_mock.return_value = action_response - - action = bound_image.change_protection(True) - - request_mock.assert_called_with( - method="POST", - url="/images/14/actions/change_protection", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - class TestImagesClient: @pytest.fixture() diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 94e5fb63..7ac3290c 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -21,13 +21,38 @@ from hcloud.networks import Network from hcloud.servers import Server +from ..conftest import BoundModelTestCase + + +class TestBoundLoadBalancer(BoundModelTestCase): + methods = [ + BoundLoadBalancer.update, + BoundLoadBalancer.delete, + BoundLoadBalancer.change_algorithm, + BoundLoadBalancer.change_dns_ptr, + BoundLoadBalancer.change_protection, + BoundLoadBalancer.change_type, + BoundLoadBalancer.add_service, + BoundLoadBalancer.update_service, + BoundLoadBalancer.delete_service, + BoundLoadBalancer.add_target, + BoundLoadBalancer.remove_target, + BoundLoadBalancer.attach_to_network, + BoundLoadBalancer.detach_from_network, + BoundLoadBalancer.disable_public_interface, + BoundLoadBalancer.enable_public_interface, + BoundLoadBalancer.get_metrics, + ] -class TestBoundLoadBalancer: @pytest.fixture() - def bound_load_balancer(self, client: Client): - return BoundLoadBalancer(client.load_balancers, data=dict(id=14)) + def resource_client(self, client: Client): + return client.load_balancers - def test_bound_load_balancer_init(self, response_load_balancer): + @pytest.fixture() + def bound_model(self, resource_client: LoadBalancersClient): + return BoundLoadBalancer(resource_client, data=dict(id=1)) + + def test_init(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( client=mock.MagicMock(), data=response_load_balancer["load_balancer"] ) @@ -35,51 +60,275 @@ def test_bound_load_balancer_init(self, response_load_balancer): assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" + +class TestLoadBalancerslient: + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_load_balancer, + ): + request_mock.return_value = response_load_balancer + + bound_load_balancer = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers/1", + ) + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + assert bound_load_balancer.outgoing_traffic == 123456 + assert bound_load_balancer.ingoing_traffic == 123456 + assert bound_load_balancer.included_traffic == 654321 + + @pytest.mark.parametrize( + "params", + [ + { + "name": "load_balancer1", + "label_selector": "label1", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, + ): + request_mock.return_value = response_simple_load_balancers + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + bound_load_balancers = result.load_balancers + assert result.meta is not None + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + @pytest.mark.parametrize( + "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancers = resource_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancer = resource_client.get_by_name("Web Frontend") + + params = {"name": "Web Frontend"} + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_create_load_balancer, + ): + request_mock.return_value = response_create_load_balancer + + response = resource_client.create( + "my-balancer", + load_balancer_type=LoadBalancerType(name="lb11"), + location=Location(id=1), + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers", + json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + ) + + bound_load_balancer = response.load_balancer + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 1 + assert bound_load_balancer.name == "my-balancer" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb11") + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb11"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type(load_balancer, LoadBalancerType(id=1)) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": 1}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_update( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_update_load_balancer, ): request_mock.return_value = response_update_load_balancer - load_balancer = bound_load_balancer.update(name="new-name", labels={}) + load_balancer = resource_client.update( + load_balancer, name="new-name", labels={} + ) request_mock.assert_called_with( method="PUT", - url="/load_balancers/14", + url="/load_balancers/1", json={"name": "new-name", "labels": {}}, ) assert load_balancer.id == 4711 assert load_balancer.name == "new-name" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_delete( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, action_response, ): request_mock.return_value = action_response - delete_success = bound_load_balancer.delete() + delete_success = resource_client.delete(load_balancer) request_mock.assert_called_with( method="DELETE", - url="/load_balancers/14", + url="/load_balancers/1", ) assert delete_success is True + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_get_metrics( self, request_mock: mock.MagicMock, - bound_load_balancer: BoundLoadBalancer, + resource_client: LoadBalancersClient, + load_balancer, response_get_metrics, ): request_mock.return_value = response_get_metrics - response = bound_load_balancer.get_metrics( + response = resource_client.get_metrics( + load_balancer, type=["requests_per_second"], start="2023-12-14T16:55:32+01:00", end="2023-12-14T16:55:32+01:00", @@ -87,7 +336,7 @@ def test_get_metrics( request_mock.assert_called_with( method="GET", - url="/load_balancers/14/metrics", + url="/load_balancers/1/metrics", params={ "type": "requests_per_second", "start": "2023-12-14T16:55:32+01:00", @@ -97,20 +346,25 @@ def test_get_metrics( assert "requests_per_second" in response.metrics.time_series assert len(response.metrics.time_series["requests_per_second"]["values"]) == 3 + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_add_service( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_add_service, ): request_mock.return_value = response_add_service service = LoadBalancerService(listen_port=80, protocol="http") - action = bound_load_balancer.add_service(service) + action = resource_client.add_service(load_balancer, service) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/add_service", + url="/load_balancers/1/actions/add_service", json={"protocol": "http", "listen_port": 80}, ) @@ -118,20 +372,25 @@ def test_add_service( assert action.progress == 100 assert action.command == "add_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_delete_service( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_delete_service, ): request_mock.return_value = response_delete_service service = LoadBalancerService(listen_port=12) - action = bound_load_balancer.delete_service(service) + action = resource_client.delete_service(load_balancer, service) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/delete_service", + url="/load_balancers/1/actions/delete_service", json={"listen_port": 12}, ) @@ -139,6 +398,10 @@ def test_delete_service( assert action.progress == 100 assert action.command == "delete_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) @pytest.mark.parametrize( "target,params", [ @@ -164,19 +427,20 @@ def test_delete_service( def test_add_target( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_add_target, target, params, ): request_mock.return_value = response_add_target - action = bound_load_balancer.add_target(target) + action = resource_client.add_target(load_balancer, target) params.update({"type": target.type}) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/add_target", + url="/load_balancers/1/actions/add_target", json=params, ) @@ -184,6 +448,10 @@ def test_add_target( assert action.progress == 100 assert action.command == "add_target" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) @pytest.mark.parametrize( "target,params", [ @@ -209,19 +477,20 @@ def test_add_target( def test_remove_target( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_remove_target, target, params, ): request_mock.return_value = response_remove_target - action = bound_load_balancer.remove_target(target) + action = resource_client.remove_target(load_balancer, target) params.update({"type": target.type}) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/remove_target", + url="/load_balancers/1/actions/remove_target", json=params, ) @@ -229,10 +498,15 @@ def test_remove_target( assert action.progress == 100 assert action.command == "remove_target" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_update_service( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_update_service, ): request_mock.return_value = response_update_service @@ -242,11 +516,11 @@ def test_update_service( ) service = LoadBalancerService(listen_port=12, health_check=new_health_check) - action = bound_load_balancer.update_service(service) + action = resource_client.update_service(load_balancer, service) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/update_service", + url="/load_balancers/1/actions/update_service", json={ "listen_port": 12, "health_check": { @@ -263,20 +537,25 @@ def test_update_service( assert action.progress == 100 assert action.command == "update_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_algorithm( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_change_algorithm, ): request_mock.return_value = response_change_algorithm algorithm = LoadBalancerAlgorithm(type="round_robin") - action = bound_load_balancer.change_algorithm(algorithm) + action = resource_client.change_algorithm(load_balancer, algorithm) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/change_algorithm", + url="/load_balancers/1/actions/change_algorithm", json={"type": "round_robin"}, ) @@ -284,21 +563,26 @@ def test_change_algorithm( assert action.progress == 100 assert action.command == "change_algorithm" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_dns_ptr( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_change_reverse_dns_entry, ): request_mock.return_value = response_change_reverse_dns_entry - action = bound_load_balancer.change_dns_ptr( - ip="1.2.3.4", dns_ptr="lb1.example.com" + action = resource_client.change_dns_ptr( + load_balancer, ip="1.2.3.4", dns_ptr="lb1.example.com" ) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/change_dns_ptr", + url="/load_balancers/1/actions/change_dns_ptr", json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, ) @@ -306,19 +590,24 @@ def test_change_dns_ptr( assert action.progress == 100 assert action.command == "change_dns_ptr" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_protection( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_change_protection, ): request_mock.return_value = response_change_protection - action = bound_load_balancer.change_protection(delete=True) + action = resource_client.change_protection(load_balancer, delete=True) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/change_protection", + url="/load_balancers/1/actions/change_protection", json={"delete": True}, ) @@ -326,57 +615,72 @@ def test_change_protection( assert action.progress == 100 assert action.command == "change_protection" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_enable_public_interface( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_enable_public_interface, ): request_mock.return_value = response_enable_public_interface - action = bound_load_balancer.enable_public_interface() + action = resource_client.enable_public_interface(load_balancer) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/enable_public_interface", + url="/load_balancers/1/actions/enable_public_interface", ) assert action.id == 13 assert action.progress == 100 assert action.command == "enable_public_interface" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_disable_public_interface( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_disable_public_interface, ): request_mock.return_value = response_disable_public_interface - action = bound_load_balancer.disable_public_interface() + action = resource_client.disable_public_interface(load_balancer) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/disable_public_interface", + url="/load_balancers/1/actions/disable_public_interface", ) assert action.id == 13 assert action.progress == 100 assert action.command == "disable_public_interface" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_attach_to_network( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_attach_load_balancer_to_network, ): request_mock.return_value = response_attach_load_balancer_to_network - action = bound_load_balancer.attach_to_network(Network(id=1)) + action = resource_client.attach_to_network(load_balancer, Network(id=1)) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/attach_to_network", + url="/load_balancers/1/actions/attach_to_network", json={"network": 1}, ) @@ -384,19 +688,24 @@ def test_attach_to_network( assert action.progress == 100 assert action.command == "attach_to_network" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_detach_from_network( self, request_mock: mock.MagicMock, - bound_load_balancer, + resource_client: LoadBalancersClient, + load_balancer, response_detach_from_network, ): request_mock.return_value = response_detach_from_network - action = bound_load_balancer.detach_from_network(Network(id=1)) + action = resource_client.detach_from_network(load_balancer, Network(id=1)) request_mock.assert_called_with( method="POST", - url="/load_balancers/14/actions/detach_from_network", + url="/load_balancers/1/actions/detach_from_network", json={"network": 1}, ) @@ -404,228 +713,27 @@ def test_detach_from_network( assert action.progress == 100 assert action.command == "detach_from_network" - def test_change_type( - self, - request_mock: mock.MagicMock, - bound_load_balancer, - action_response, - ): - request_mock.return_value = action_response - - action = bound_load_balancer.change_type(LoadBalancerType(name="lb21")) - - request_mock.assert_called_with( - method="POST", - url="/load_balancers/14/actions/change_type", - json={"load_balancer_type": "lb21"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - -class TestLoadBalancerslient: - @pytest.fixture() - def load_balancers_client(self, client: Client): - return LoadBalancersClient(client) - - def test_get_by_id( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_load_balancer, - ): - request_mock.return_value = response_load_balancer - - bound_load_balancer = load_balancers_client.get_by_id(1) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers/1", - ) - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" - assert bound_load_balancer.outgoing_traffic == 123456 - assert bound_load_balancer.ingoing_traffic == 123456 - assert bound_load_balancer.included_traffic == 654321 - - @pytest.mark.parametrize( - "params", - [ - { - "name": "load_balancer1", - "label_selector": "label1", - "page": 1, - "per_page": 10, - }, - {"name": ""}, - {}, - ], - ) - def test_get_list( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_simple_load_balancers, - params, - ): - request_mock.return_value = response_simple_load_balancers - - result = load_balancers_client.get_list(**params) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers", - params=params, - ) - - bound_load_balancers = result.load_balancers - assert result.meta is not None - - assert len(bound_load_balancers) == 2 - - bound_load_balancer1 = bound_load_balancers[0] - bound_load_balancer2 = bound_load_balancers[1] - - assert bound_load_balancer1._client is load_balancers_client - assert bound_load_balancer1.id == 4711 - assert bound_load_balancer1.name == "Web Frontend" - - assert bound_load_balancer2._client is load_balancers_client - assert bound_load_balancer2.id == 4712 - assert bound_load_balancer2.name == "Web Frontend2" - - @pytest.mark.parametrize( - "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] - ) - def test_get_all( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_simple_load_balancers, - params, - ): - request_mock.return_value = response_simple_load_balancers - - bound_load_balancers = load_balancers_client.get_all(**params) - - params.update({"page": 1, "per_page": 50}) - - request_mock.assert_called_with( - method="GET", - url="/load_balancers", - params=params, - ) - - assert len(bound_load_balancers) == 2 - - bound_load_balancer1 = bound_load_balancers[0] - bound_load_balancer2 = bound_load_balancers[1] - - assert bound_load_balancer1._client is load_balancers_client - assert bound_load_balancer1.id == 4711 - assert bound_load_balancer1.name == "Web Frontend" - - assert bound_load_balancer2._client is load_balancers_client - assert bound_load_balancer2.id == 4712 - assert bound_load_balancer2.name == "Web Frontend2" - - def test_get_by_name( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_simple_load_balancers, - ): - request_mock.return_value = response_simple_load_balancers - - bound_load_balancer = load_balancers_client.get_by_name("Web Frontend") - - params = {"name": "Web Frontend"} - - request_mock.assert_called_with( - method="GET", - url="/load_balancers", - params=params, - ) - - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" - - def test_create( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - response_create_load_balancer, - ): - request_mock.return_value = response_create_load_balancer - - response = load_balancers_client.create( - "my-balancer", - load_balancer_type=LoadBalancerType(name="lb11"), - location=Location(id=1), - ) - - request_mock.assert_called_with( - method="POST", - url="/load_balancers", - json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, - ) - - bound_load_balancer = response.load_balancer - - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 1 - assert bound_load_balancer.name == "my-balancer" - @pytest.mark.parametrize( "load_balancer", [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], ) - def test_change_type_with_load_balancer_type_name( - self, - request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, - load_balancer, - action_response, - ): - request_mock.return_value = action_response - - action = load_balancers_client.change_type( - load_balancer, LoadBalancerType(name="lb11") - ) - - request_mock.assert_called_with( - method="POST", - url="/load_balancers/1/actions/change_type", - json={"load_balancer_type": "lb11"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "load_balancer", - [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], - ) - def test_change_type_with_load_balancer_type_id( + def test_change_type( self, request_mock: mock.MagicMock, - load_balancers_client: LoadBalancersClient, + resource_client: LoadBalancersClient, load_balancer, action_response, ): request_mock.return_value = action_response - action = load_balancers_client.change_type( - load_balancer, LoadBalancerType(id=1) + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb21") ) request_mock.assert_called_with( method="POST", url="/load_balancers/1/actions/change_type", - json={"load_balancer_type": 1}, + json={"load_balancer_type": "lb21"}, ) assert action.id == 1 diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 5b60983b..3f162b46 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -15,13 +15,30 @@ ) from hcloud.servers import BoundServer +from ..conftest import BoundModelTestCase + + +class TestBoundNetwork(BoundModelTestCase): + methods = [ + BoundNetwork.update, + BoundNetwork.delete, + BoundNetwork.add_subnet, + BoundNetwork.delete_subnet, + BoundNetwork.add_route, + BoundNetwork.delete_route, + BoundNetwork.change_ip_range, + BoundNetwork.change_protection, + ] -class TestBoundNetwork: @pytest.fixture() - def bound_network(self, client: Client): - return BoundNetwork(client.networks, data=dict(id=14)) + def resource_client(self, client: Client): + return client.networks - def test_bound_network_init(self, network_response): + @pytest.fixture() + def bound_model(self, resource_client: NetworksClient): + return BoundNetwork(resource_client, data=dict(id=14)) + + def test_init(self, network_response): bound_network = BoundNetwork( client=mock.MagicMock(), data=network_response["network"] ) @@ -49,168 +66,6 @@ def test_bound_network_init(self, network_response): assert bound_network.routes[0].destination == "10.100.1.0/24" assert bound_network.routes[0].gateway == "10.0.1.1" - def test_update( - self, - request_mock: mock.MagicMock, - bound_network, - response_update_network, - ): - request_mock.return_value = response_update_network - - network = bound_network.update(name="new-name") - - request_mock.assert_called_with( - method="PUT", - url="/networks/14", - json={"name": "new-name"}, - ) - - assert network.id == 4711 - assert network.name == "new-name" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_network.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/networks/14", - ) - - assert delete_success is True - - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - action = bound_network.change_protection(True) - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/change_protection", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_add_subnet( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - subnet = NetworkSubnet( - type=NetworkSubnet.TYPE_CLOUD, - ip_range="10.0.1.0/24", - network_zone="eu-central", - ) - action = bound_network.add_subnet(subnet) - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/add_subnet", - json={ - "type": NetworkSubnet.TYPE_CLOUD, - "ip_range": "10.0.1.0/24", - "network_zone": "eu-central", - }, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_delete_subnet( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - subnet = NetworkSubnet(ip_range="10.0.1.0/24") - action = bound_network.delete_subnet(subnet) - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/delete_subnet", - json={"ip_range": "10.0.1.0/24"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_add_route( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - action = bound_network.add_route(route) - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/add_route", - json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_delete_route( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - action = bound_network.delete_route(route) - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/delete_route", - json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_ip( - self, - request_mock: mock.MagicMock, - bound_network, - action_response, - ): - request_mock.return_value = action_response - - action = bound_network.change_ip_range("10.0.0.0/12") - - request_mock.assert_called_with( - method="POST", - url="/networks/14/actions/change_ip_range", - json={"ip_range": "10.0.0.0/12"}, - ) - - assert action.id == 1 - assert action.progress == 0 - class TestNetworksClient: @pytest.fixture() diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index fc1f2d57..0c35d41b 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -5,10 +5,15 @@ import pytest from hcloud import Client -from hcloud.placement_groups import BoundPlacementGroup, PlacementGroupsClient +from hcloud.placement_groups import ( + BoundPlacementGroup, + PlacementGroupsClient, +) +from ..conftest import BoundModelTestCase -def check_variables(placement_group, expected): + +def check_variables(placement_group: BoundPlacementGroup, expected): assert placement_group.id == expected["id"] assert placement_group.name == expected["name"] assert placement_group.labels == expected["labels"] @@ -16,12 +21,21 @@ def check_variables(placement_group, expected): assert placement_group.type == expected["type"] -class TestBoundPlacementGroup: +class TestBoundPlacementGroup(BoundModelTestCase): + methods = [ + BoundPlacementGroup.update, + BoundPlacementGroup.delete, + ] + @pytest.fixture() - def bound_placement_group(self, client: Client): - return BoundPlacementGroup(client.placement_groups, data=dict(id=897)) + def resource_client(self, client: Client): + return client.placement_groups - def test_bound_placement_group_init(self, placement_group_response): + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) + + def test_init(self, placement_group_response): bound_placement_group = BoundPlacementGroup( client=mock.MagicMock(), data=placement_group_response["placement_group"] ) @@ -30,61 +44,25 @@ def test_bound_placement_group_init(self, placement_group_response): bound_placement_group, placement_group_response["placement_group"] ) - def test_update( - self, - request_mock: mock.MagicMock, - bound_placement_group, - placement_group_response, - ): - request_mock.return_value = placement_group_response - - placement_group = bound_placement_group.update( - name=placement_group_response["placement_group"]["name"], - labels=placement_group_response["placement_group"]["labels"], - ) - - request_mock.assert_called_with( - method="PUT", - url="/placement_groups/{placement_group_id}".format( - placement_group_id=placement_group_response["placement_group"]["id"] - ), - json={ - "labels": placement_group_response["placement_group"]["labels"], - "name": placement_group_response["placement_group"]["name"], - }, - ) - - check_variables(placement_group, placement_group_response["placement_group"]) - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_placement_group, - ): - delete_success = bound_placement_group.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/placement_groups/897", - ) - - assert delete_success is True - class TestPlacementGroupsClient: @pytest.fixture() - def placement_groups_client(self, client: Client): - return PlacementGroupsClient(client) + def resource_client(self, client: Client): + return client.placement_groups + + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) def test_get_by_id( self, request_mock: mock.MagicMock, - placement_groups_client: PlacementGroupsClient, + resource_client: PlacementGroupsClient, placement_group_response, ): request_mock.return_value = placement_group_response - placement_group = placement_groups_client.get_by_id( + placement_group = resource_client.get_by_id( placement_group_response["placement_group"]["id"] ) @@ -95,7 +73,7 @@ def test_get_by_id( ), ) - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, placement_group_response["placement_group"]) @@ -116,13 +94,13 @@ def test_get_by_id( def test_get_list( self, request_mock: mock.MagicMock, - placement_groups_client: PlacementGroupsClient, + resource_client: PlacementGroupsClient, two_placement_groups_response, params, ): request_mock.return_value = two_placement_groups_response - result = placement_groups_client.get_list(**params) + result = resource_client.get_list(**params) request_mock.assert_called_with( method="GET", @@ -140,7 +118,7 @@ def test_get_list( for placement_group, expected in zip( placement_groups, two_placement_groups_response["placement_groups"] ): - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, expected) @@ -158,13 +136,13 @@ def test_get_list( def test_get_all( self, request_mock: mock.MagicMock, - placement_groups_client: PlacementGroupsClient, + resource_client: PlacementGroupsClient, two_placement_groups_response, params, ): request_mock.return_value = two_placement_groups_response - placement_groups = placement_groups_client.get_all(**params) + placement_groups = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) @@ -181,19 +159,19 @@ def test_get_all( for placement_group, expected in zip( placement_groups, two_placement_groups_response["placement_groups"] ): - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, expected) def test_get_by_name( self, request_mock: mock.MagicMock, - placement_groups_client: PlacementGroupsClient, + resource_client: PlacementGroupsClient, one_placement_group_response, ): request_mock.return_value = one_placement_group_response - placement_group = placement_groups_client.get_by_name( + placement_group = resource_client.get_by_name( one_placement_group_response["placement_groups"][0]["name"] ) @@ -212,12 +190,12 @@ def test_get_by_name( def test_create( self, request_mock: mock.MagicMock, - placement_groups_client: PlacementGroupsClient, + resource_client: PlacementGroupsClient, response_create_placement_group, ): request_mock.return_value = response_create_placement_group - response = placement_groups_client.create( + response = resource_client.create( name=response_create_placement_group["placement_group"]["name"], type=response_create_placement_group["placement_group"]["type"], labels=response_create_placement_group["placement_group"]["labels"], @@ -237,7 +215,48 @@ def test_create( bound_placement_group = response.placement_group - assert bound_placement_group._client is placement_groups_client + assert bound_placement_group._client is resource_client check_variables( bound_placement_group, response_create_placement_group["placement_group"] ) + + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + bound_model, + placement_group_response, + ): + request_mock.return_value = placement_group_response + + placement_group = resource_client.update( + bound_model, + name=placement_group_response["placement_group"]["name"], + labels=placement_group_response["placement_group"]["labels"], + ) + + request_mock.assert_called_with( + method="PUT", + url="/placement_groups/897", + json={ + "labels": placement_group_response["placement_group"]["labels"], + "name": placement_group_response["placement_group"]["name"], + }, + ) + + check_variables(placement_group, placement_group_response["placement_group"]) + + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + bound_model, + ): + delete_success = resource_client.delete(bound_model) + + request_mock.assert_called_with( + method="DELETE", + url="/placement_groups/897", + ) + + assert delete_success is True diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index ee3f5b4d..8886a01e 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -8,13 +8,28 @@ from hcloud.datacenters import BoundDatacenter, Datacenter from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient +from ..conftest import BoundModelTestCase + + +class TestBoundPrimaryIP(BoundModelTestCase): + methods = [ + BoundPrimaryIP.update, + BoundPrimaryIP.delete, + BoundPrimaryIP.change_dns_ptr, + BoundPrimaryIP.change_protection, + BoundPrimaryIP.assign, + BoundPrimaryIP.unassign, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.primary_ips -class TestBoundPrimaryIP: @pytest.fixture() - def bound_primary_ip(self, client: Client): - return BoundPrimaryIP(client.primary_ips, data=dict(id=14)) + def bound_model(self, resource_client: PrimaryIPsClient): + return BoundPrimaryIP(resource_client, data=dict(id=14)) - def test_bound_primary_ip_init(self, primary_ip_response): + def test_init(self, primary_ip_response): bound_primary_ip = BoundPrimaryIP( client=mock.MagicMock(), data=primary_ip_response["primary_ip"] ) @@ -39,114 +54,6 @@ def test_bound_primary_ip_init(self, primary_ip_response): assert bound_primary_ip.datacenter.location.latitude == 50.47612 assert bound_primary_ip.datacenter.location.longitude == 12.370071 - def test_update( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - response_update_primary_ip, - ): - request_mock.return_value = response_update_primary_ip - - primary_ip = bound_primary_ip.update(auto_delete=True, name="my-resource") - - request_mock.assert_called_with( - method="PUT", - url="/primary_ips/14", - json={"auto_delete": True, "name": "my-resource"}, - ) - - assert primary_ip.id == 42 - assert primary_ip.auto_delete is True - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_primary_ip.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/primary_ips/14", - ) - - assert delete_success is True - - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_primary_ip.change_protection(True) - - request_mock.assert_called_with( - method="POST", - url="/primary_ips/14/actions/change_protection", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_assign( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_primary_ip.assign(assignee_id=12, assignee_type="server") - - request_mock.assert_called_with( - method="POST", - url="/primary_ips/14/actions/assign", - json={"assignee_id": 12, "assignee_type": "server"}, - ) - assert action.id == 1 - assert action.progress == 0 - - def test_unassign( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_primary_ip.unassign() - - request_mock.assert_called_with( - method="POST", - url="/primary_ips/14/actions/unassign", - ) - assert action.id == 1 - assert action.progress == 0 - - def test_change_dns_ptr( - self, - request_mock: mock.MagicMock, - bound_primary_ip, - action_response, - ): - request_mock.return_value = action_response - - action = bound_primary_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - - request_mock.assert_called_with( - method="POST", - url="/primary_ips/14/actions/change_dns_ptr", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, - ) - assert action.id == 1 - assert action.progress == 0 - class TestPrimaryIPsClient: @pytest.fixture() diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 423dc29b..f3cb490e 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -27,630 +27,135 @@ ) from hcloud.volumes import BoundVolume, Volume +from ..conftest import BoundModelTestCase + + +class TestBoundServer(BoundModelTestCase): + methods = [ + BoundServer.update, + BoundServer.delete, + BoundServer.add_to_placement_group, + BoundServer.remove_from_placement_group, + BoundServer.attach_iso, + BoundServer.detach_iso, + BoundServer.attach_to_network, + BoundServer.detach_from_network, + BoundServer.change_alias_ips, + BoundServer.change_dns_ptr, + BoundServer.change_protection, + BoundServer.change_type, + BoundServer.create_image, + BoundServer.disable_backup, + BoundServer.enable_backup, + BoundServer.disable_rescue, + BoundServer.enable_rescue, + BoundServer.get_metrics, + BoundServer.power_off, + BoundServer.power_on, + BoundServer.reboot, + BoundServer.rebuild, + BoundServer.shutdown, + BoundServer.reset, + BoundServer.request_console, + BoundServer.reset_password, + ] -class TestBoundServer: @pytest.fixture() - def bound_server(self, client: Client): - return BoundServer(client.servers, data=dict(id=14)) + def resource_client(self, client: Client): + return client.servers - def test_bound_server_init(self, response_full_server): - bound_server = BoundServer( - client=mock.MagicMock(), data=response_full_server["server"] - ) - - assert bound_server.id == 42 - assert bound_server.name == "my-server" - assert bound_server.primary_disk_size == 20 - assert isinstance(bound_server.public_net, PublicNetwork) - assert isinstance(bound_server.public_net.ipv4, IPv4Address) - assert bound_server.public_net.ipv4.ip == "1.2.3.4" - assert bound_server.public_net.ipv4.blocked is False - assert bound_server.public_net.ipv4.dns_ptr == "server01.example.com" - - assert isinstance(bound_server.public_net.ipv6, IPv6Network) - assert bound_server.public_net.ipv6.ip == "2001:db8::/64" - assert bound_server.public_net.ipv6.blocked is False - assert bound_server.public_net.ipv6.network == "2001:db8::" - assert bound_server.public_net.ipv6.network_mask == "64" - - assert isinstance(bound_server.public_net.firewalls, list) - assert isinstance(bound_server.public_net.firewalls[0], PublicNetworkFirewall) - firewall = bound_server.public_net.firewalls[0] - assert isinstance(firewall.firewall, BoundFirewall) - assert bound_server.public_net.ipv6.blocked is False - assert firewall.status == PublicNetworkFirewall.STATUS_APPLIED - - assert isinstance(bound_server.public_net.floating_ips[0], BoundFloatingIP) - assert bound_server.public_net.floating_ips[0].id == 478 - assert bound_server.public_net.floating_ips[0].complete is False - - assert isinstance(bound_server.datacenter, BoundDatacenter) - assert ( - bound_server.datacenter._client == bound_server._client._parent.datacenters - ) - assert bound_server.datacenter.id == 1 - assert bound_server.datacenter.complete is True - - assert isinstance(bound_server.server_type, BoundServerType) - assert ( - bound_server.server_type._client - == bound_server._client._parent.server_types - ) - assert bound_server.server_type.id == 1 - assert bound_server.server_type.complete is True - - assert len(bound_server.volumes) == 2 - assert isinstance(bound_server.volumes[0], BoundVolume) - assert bound_server.volumes[0]._client == bound_server._client._parent.volumes - assert bound_server.volumes[0].id == 1 - assert bound_server.volumes[0].complete is False - - assert isinstance(bound_server.volumes[1], BoundVolume) - assert bound_server.volumes[1]._client == bound_server._client._parent.volumes - assert bound_server.volumes[1].id == 2 - assert bound_server.volumes[1].complete is False - - assert isinstance(bound_server.image, BoundImage) - assert bound_server.image._client == bound_server._client._parent.images - assert bound_server.image.id == 4711 - assert bound_server.image.name == "ubuntu-20.04" - assert bound_server.image.complete is True - - assert isinstance(bound_server.iso, BoundIso) - assert bound_server.iso._client == bound_server._client._parent.isos - assert bound_server.iso.id == 4711 - assert bound_server.iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - assert bound_server.iso.complete is True - - assert len(bound_server.private_net) == 1 - assert isinstance(bound_server.private_net[0], PrivateNet) - assert ( - bound_server.private_net[0].network._client - == bound_server._client._parent.networks - ) - assert bound_server.private_net[0].ip == "10.1.1.5" - assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" - assert len(bound_server.private_net[0].alias_ips) == 1 - assert bound_server.private_net[0].alias_ips[0] == "10.1.1.8" - - assert isinstance(bound_server.placement_group, BoundPlacementGroup) - assert ( - bound_server.placement_group._client - == bound_server._client._parent.placement_groups - ) - assert bound_server.placement_group.id == 897 - assert bound_server.placement_group.name == "my Placement Group" - assert bound_server.placement_group.complete is True - - def test_update( - self, - request_mock: mock.MagicMock, - bound_server, - response_update_server, - ): - request_mock.return_value = response_update_server - - server = bound_server.update(name="new-name", labels={}) - - request_mock.assert_called_with( - method="PUT", - url="/servers/14", - json={"name": "new-name", "labels": {}}, - ) - - assert server.id == 14 - assert server.name == "new-name" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/servers/14", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_get_metrics( - self, - request_mock: mock.MagicMock, - bound_server: BoundServer, - response_get_metrics, - ): - request_mock.return_value = response_get_metrics - - response = bound_server.get_metrics( - type=["cpu", "disk"], - start="2023-12-14T17:40:00+01:00", - end="2023-12-14T17:50:00+01:00", - ) - - request_mock.assert_called_with( - method="GET", - url="/servers/14/metrics", - params={ - "type": "cpu,disk", - "start": "2023-12-14T17:40:00+01:00", - "end": "2023-12-14T17:50:00+01:00", - }, - ) - - assert "cpu" in response.metrics.time_series - assert "disk.0.iops.read" in response.metrics.time_series - assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 - - def test_power_off( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.power_off() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/poweroff", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_power_on( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.power_on() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/poweron", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reboot( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.reboot() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/reboot", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reset( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.reset() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/reset", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_shutdown( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.shutdown() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/shutdown", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reset_password( - self, - request_mock: mock.MagicMock, - bound_server, - response_server_reset_password, - ): - request_mock.return_value = response_server_reset_password - - response = bound_server.reset_password() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/reset_password", - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password == "YItygq1v3GYjjMomLaKc" - - def test_change_type( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/change_type", - json={"server_type": "cx11", "upgrade_disk": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_enable_rescue( - self, - request_mock: mock.MagicMock, - bound_server, - response_server_enable_rescue, - ): - request_mock.return_value = response_server_enable_rescue - - response = bound_server.enable_rescue(type="linux64") - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/enable_rescue", - json={"type": "linux64"}, - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password == "YItygq1v3GYjjMomLaKc" - - def test_disable_rescue( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.disable_rescue() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/disable_rescue", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_create_image( - self, - request_mock: mock.MagicMock, - bound_server, - response_server_create_image, - ): - request_mock.return_value = response_server_create_image - - response = bound_server.create_image(description="my image", type="snapshot") - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/create_image", - json={"description": "my image", "type": "snapshot"}, - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.image.description == "my image" - - def test_rebuild( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - response = bound_server.rebuild( - Image(name="ubuntu-20.04"), - return_response=True, - ) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/rebuild", - json={"image": "ubuntu-20.04"}, - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password is None or isinstance(response.root_password, str) - - def test_enable_backup( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.enable_backup() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/enable_backup", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_disable_backup( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.disable_backup() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/disable_backup", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_attach_iso( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/attach_iso", - json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_detach_iso( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.detach_iso() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/detach_iso", - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_dns_ptr( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.change_dns_ptr("1.2.3.4", "example.com") - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/change_dns_ptr", - json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_server.change_protection(True, True) + @pytest.fixture() + def bound_model(self, resource_client: ServersClient): + return BoundServer(resource_client, data=dict(id=14)) - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/change_protection", - json={"delete": True, "rebuild": True}, + def test_init(self, response_full_server): + bound_server = BoundServer( + client=mock.MagicMock(), data=response_full_server["server"] ) - assert action.id == 1 - assert action.progress == 0 + assert bound_server.id == 42 + assert bound_server.name == "my-server" + assert bound_server.primary_disk_size == 20 + assert isinstance(bound_server.public_net, PublicNetwork) + assert isinstance(bound_server.public_net.ipv4, IPv4Address) + assert bound_server.public_net.ipv4.ip == "1.2.3.4" + assert bound_server.public_net.ipv4.blocked is False + assert bound_server.public_net.ipv4.dns_ptr == "server01.example.com" - def test_request_console( - self, - request_mock: mock.MagicMock, - bound_server, - response_server_request_console, - ): - request_mock.return_value = response_server_request_console + assert isinstance(bound_server.public_net.ipv6, IPv6Network) + assert bound_server.public_net.ipv6.ip == "2001:db8::/64" + assert bound_server.public_net.ipv6.blocked is False + assert bound_server.public_net.ipv6.network == "2001:db8::" + assert bound_server.public_net.ipv6.network_mask == "64" - response = bound_server.request_console() + assert isinstance(bound_server.public_net.firewalls, list) + assert isinstance(bound_server.public_net.firewalls[0], PublicNetworkFirewall) + firewall = bound_server.public_net.firewalls[0] + assert isinstance(firewall.firewall, BoundFirewall) + assert bound_server.public_net.ipv6.blocked is False + assert firewall.status == PublicNetworkFirewall.STATUS_APPLIED - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/request_console", - ) + assert isinstance(bound_server.public_net.floating_ips[0], BoundFloatingIP) + assert bound_server.public_net.floating_ips[0].id == 478 + assert bound_server.public_net.floating_ips[0].complete is False - assert response.action.id == 1 - assert response.action.progress == 0 + assert isinstance(bound_server.datacenter, BoundDatacenter) assert ( - response.wss_url - == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" - ) - assert response.password == "9MQaTg2VAGI0FIpc10k3UpRXcHj2wQ6x" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_attach_to_network( - self, - request_mock: mock.MagicMock, - bound_server, - network, - response_attach_to_network, - ): - request_mock.return_value = response_attach_to_network - - action = bound_server.attach_to_network( - network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] - ) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/attach_to_network", - json={ - "network": 4711, - "ip": "10.0.1.1", - "alias_ips": ["10.0.1.2", "10.0.1.3"], - }, + bound_server.datacenter._client == bound_server._client._parent.datacenters ) + assert bound_server.datacenter.id == 1 + assert bound_server.datacenter.complete is True - assert action.id == 1 - assert action.progress == 0 - assert action.command == "attach_to_network" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_detach_from_network( - self, - request_mock: mock.MagicMock, - bound_server, - network, - response_detach_from_network, - ): - request_mock.return_value = response_detach_from_network - - action = bound_server.detach_from_network(network) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/detach_from_network", - json={"network": 4711}, + assert isinstance(bound_server.server_type, BoundServerType) + assert ( + bound_server.server_type._client + == bound_server._client._parent.server_types ) + assert bound_server.server_type.id == 1 + assert bound_server.server_type.complete is True - assert action.id == 1 - assert action.progress == 0 - assert action.command == "detach_from_network" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_change_alias_ips( - self, - request_mock: mock.MagicMock, - bound_server, - network, - response_change_alias_ips, - ): - request_mock.return_value = response_change_alias_ips - - action = bound_server.change_alias_ips(network, ["10.0.1.2", "10.0.1.3"]) - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/change_alias_ips", - json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, - ) + assert len(bound_server.volumes) == 2 + assert isinstance(bound_server.volumes[0], BoundVolume) + assert bound_server.volumes[0]._client == bound_server._client._parent.volumes + assert bound_server.volumes[0].id == 1 + assert bound_server.volumes[0].complete is False - assert action.id == 1 - assert action.progress == 0 - assert action.command == "change_alias_ips" + assert isinstance(bound_server.volumes[1], BoundVolume) + assert bound_server.volumes[1]._client == bound_server._client._parent.volumes + assert bound_server.volumes[1].id == 2 + assert bound_server.volumes[1].complete is False - @pytest.mark.parametrize( - "placement_group", - [PlacementGroup(id=897), BoundPlacementGroup(mock.MagicMock, dict(id=897))], - ) - def test_add_to_placement_group( - self, - request_mock: mock.MagicMock, - bound_server, - placement_group, - response_add_to_placement_group, - ): - request_mock.return_value = response_add_to_placement_group + assert isinstance(bound_server.image, BoundImage) + assert bound_server.image._client == bound_server._client._parent.images + assert bound_server.image.id == 4711 + assert bound_server.image.name == "ubuntu-20.04" + assert bound_server.image.complete is True - action = bound_server.add_to_placement_group(placement_group) + assert isinstance(bound_server.iso, BoundIso) + assert bound_server.iso._client == bound_server._client._parent.isos + assert bound_server.iso.id == 4711 + assert bound_server.iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" + assert bound_server.iso.complete is True - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/add_to_placement_group", - json={"placement_group": 897}, + assert len(bound_server.private_net) == 1 + assert isinstance(bound_server.private_net[0], PrivateNet) + assert ( + bound_server.private_net[0].network._client + == bound_server._client._parent.networks ) + assert bound_server.private_net[0].ip == "10.1.1.5" + assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" + assert len(bound_server.private_net[0].alias_ips) == 1 + assert bound_server.private_net[0].alias_ips[0] == "10.1.1.8" - assert action.id == 13 - assert action.progress == 0 - assert action.command == "add_to_placement_group" - - def test_remove_from_placement_group( - self, - request_mock: mock.MagicMock, - bound_server, - response_remove_from_placement_group, - ): - request_mock.return_value = response_remove_from_placement_group - - action = bound_server.remove_from_placement_group() - - request_mock.assert_called_with( - method="POST", - url="/servers/14/actions/remove_from_placement_group", + assert isinstance(bound_server.placement_group, BoundPlacementGroup) + assert ( + bound_server.placement_group._client + == bound_server._client._parent.placement_groups ) - - assert action.id == 13 - assert action.progress == 100 - assert action.command == "remove_from_placement_group" + assert bound_server.placement_group.id == 897 + assert bound_server.placement_group.name == "my Placement Group" + assert bound_server.placement_group.complete is True class TestServersClient: @@ -1640,3 +1145,84 @@ def test_change_alias_ips( assert action.id == 1 assert action.progress == 0 assert action.command == "change_alias_ips" + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "placement_group", + [PlacementGroup(id=897), BoundPlacementGroup(mock.MagicMock, dict(id=897))], + ) + def test_add_to_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + placement_group, + response_add_to_placement_group, + ): + request_mock.return_value = response_add_to_placement_group + + action = servers_client.add_to_placement_group(server, placement_group) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/add_to_placement_group", + json={"placement_group": 897}, + ) + + assert action.id == 13 + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_remove_from_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_remove_from_placement_group, + ): + request_mock.return_value = response_remove_from_placement_group + + action = servers_client.remove_from_placement_group(server) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/remove_from_placement_group", + ) + + assert action.id == 13 + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_get_metrics( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_get_metrics, + ): + request_mock.return_value = response_get_metrics + + response = servers_client.get_metrics( + server, + type=["cpu", "disk"], + start="2023-12-14T17:40:00+01:00", + end="2023-12-14T17:50:00+01:00", + ) + + request_mock.assert_called_with( + method="GET", + url="/servers/1/metrics", + params={ + "type": "cpu,disk", + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + }, + ) + + assert "cpu" in response.metrics.time_series + assert "disk.0.iops.read" in response.metrics.time_series + assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 31e58e71..bed07b54 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -7,13 +7,24 @@ from hcloud import Client from hcloud.ssh_keys import BoundSSHKey, SSHKey, SSHKeysClient +from ..conftest import BoundModelTestCase + + +class TestBoundSSHKey(BoundModelTestCase): + methods = [ + BoundSSHKey.update, + BoundSSHKey.delete, + ] -class TestBoundSSHKey: @pytest.fixture() - def bound_ssh_key(self, client: Client): - return BoundSSHKey(client.ssh_keys, data=dict(id=14)) + def resource_client(self, client: Client) -> SSHKeysClient: + return client.ssh_keys - def test_bound_ssh_key_init(self, ssh_key_response): + @pytest.fixture() + def bound_model(self, resource_client: SSHKeysClient) -> BoundSSHKey: + return BoundSSHKey(resource_client, data=dict(id=14)) + + def test_init(self, ssh_key_response): bound_ssh_key = BoundSSHKey( client=mock.MagicMock(), data=ssh_key_response["ssh_key"] ) @@ -26,42 +37,6 @@ def test_bound_ssh_key_init(self, ssh_key_response): ) assert bound_ssh_key.public_key == "ssh-rsa AAAjjk76kgf...Xt" - def test_update( - self, - request_mock: mock.MagicMock, - bound_ssh_key, - response_update_ssh_key, - ): - request_mock.return_value = response_update_ssh_key - - ssh_key = bound_ssh_key.update(name="New name") - - request_mock.assert_called_with( - method="PUT", - url="/ssh_keys/14", - json={"name": "New name"}, - ) - - assert ssh_key.id == 2323 - assert ssh_key.name == "New name" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_ssh_key, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_ssh_key.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/ssh_keys/14", - ) - - assert delete_success is True - class TestSSHKeysClient: @pytest.fixture() diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index 9a1d0b20..dd86f22b 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -10,11 +10,26 @@ from hcloud.servers import BoundServer, Server from hcloud.volumes import BoundVolume, Volume, VolumesClient +from ..conftest import BoundModelTestCase + + +class TestBoundVolume(BoundModelTestCase): + methods = [ + BoundVolume.update, + BoundVolume.delete, + BoundVolume.change_protection, + BoundVolume.attach, + BoundVolume.detach, + BoundVolume.resize, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.volumes -class TestBoundVolume: @pytest.fixture() - def bound_volume(self, client: Client): - return BoundVolume(client.volumes, data=dict(id=14)) + def bound_model(self, resource_client): + return BoundVolume(resource_client, data=dict(id=14)) def test_bound_volume_init(self, volume_response): bound_volume = BoundVolume( @@ -41,140 +56,6 @@ def test_bound_volume_init(self, volume_response): assert bound_volume.location.latitude == 50.47612 assert bound_volume.location.longitude == 12.370071 - def test_update( - self, - request_mock: mock.MagicMock, - bound_volume, - response_update_volume, - ): - request_mock.return_value = response_update_volume - - volume = bound_volume.update(name="new-name") - - request_mock.assert_called_with( - method="PUT", - url="/volumes/14", - json={"name": "new-name"}, - ) - - assert volume.id == 4711 - assert volume.name == "new-name" - - def test_delete( - self, - request_mock: mock.MagicMock, - bound_volume, - action_response, - ): - request_mock.return_value = action_response - - delete_success = bound_volume.delete() - - request_mock.assert_called_with( - method="DELETE", - url="/volumes/14", - ) - - assert delete_success is True - - def test_change_protection( - self, - request_mock: mock.MagicMock, - bound_volume, - action_response, - ): - request_mock.return_value = action_response - - action = bound_volume.change_protection(True) - - request_mock.assert_called_with( - method="POST", - url="/volumes/14/actions/change_protection", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_attach( - self, - request_mock: mock.MagicMock, - bound_volume, - server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_volume.attach(server) - - request_mock.assert_called_with( - method="POST", - url="/volumes/14/actions/attach", - json={"server": 1}, - ) - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_attach_with_automount( - self, - request_mock: mock.MagicMock, - bound_volume, - server, - action_response, - ): - request_mock.return_value = action_response - - action = bound_volume.attach(server, False) - - request_mock.assert_called_with( - method="POST", - url="/volumes/14/actions/attach", - json={"server": 1, "automount": False}, - ) - assert action.id == 1 - assert action.progress == 0 - - def test_detach( - self, - request_mock: mock.MagicMock, - bound_volume, - action_response, - ): - request_mock.return_value = action_response - - action = bound_volume.detach() - - request_mock.assert_called_with( - method="POST", - url="/volumes/14/actions/detach", - ) - assert action.id == 1 - assert action.progress == 0 - - def test_resize( - self, - request_mock: mock.MagicMock, - bound_volume, - action_response, - ): - request_mock.return_value = action_response - - action = bound_volume.resize(50) - - request_mock.assert_called_with( - method="POST", - url="/volumes/14/actions/resize", - json={"size": 50}, - ) - assert action.id == 1 - assert action.progress == 0 - class TestVolumesClient: @pytest.fixture() @@ -343,7 +224,11 @@ def test_create_with_server( request_mock.return_value = volume_create_response volumes_client.create( - 100, "database-storage", server=server, automount=False, format="xfs" + size=100, + name="database-storage", + server=server, + automount=False, + format="xfs", ) request_mock.assert_called_with( @@ -476,12 +361,12 @@ def test_attach( ): request_mock.return_value = action_response - action = volumes_client.attach(volume, server) + action = volumes_client.attach(volume, server, True) request_mock.assert_called_with( method="POST", url="/volumes/12/actions/attach", - json={"server": 1}, + json={"server": 1, "automount": True}, ) assert action.id == 1 assert action.progress == 0 diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py index 91165ad1..e20aaedd 100644 --- a/tests/unit/zones/test_client.py +++ b/tests/unit/zones/test_client.py @@ -19,7 +19,7 @@ ZonesClient, ) -from ..conftest import assert_bound_action1 +from ..conftest import BoundModelTestCase, assert_bound_action1 def assert_bound_zone1(o: BoundZone, client: ZonesClient): @@ -847,13 +847,35 @@ def test_set_rrset_records( assert_bound_action1(action, resource_client._parent.actions) -class TestBoundZone: +class TestBoundZone(BoundModelTestCase): + methods = [ + BoundZone.update, + BoundZone.delete, + BoundZone.import_zonefile, + BoundZone.export_zonefile, + BoundZone.change_primary_nameservers, + BoundZone.change_ttl, + BoundZone.change_protection, + BoundZone.get_rrset_all, + BoundZone.get_rrset_list, + BoundZone.get_rrset, + BoundZone.create_rrset, + # With rrset sub resource + (BoundZone.update_rrset, {"sub_resource": True}), + (BoundZone.delete_rrset, {"sub_resource": True}), + (BoundZone.change_rrset_protection, {"sub_resource": True}), + (BoundZone.change_rrset_ttl, {"sub_resource": True}), + (BoundZone.add_rrset_records, {"sub_resource": True}), + (BoundZone.remove_rrset_records, {"sub_resource": True}), + (BoundZone.set_rrset_records, {"sub_resource": True}), + ] + @pytest.fixture() def resource_client(self, client: Client): return client.zones @pytest.fixture() - def bound_model(self, resource_client, zone1): + def bound_model(self, resource_client: ZonesClient, zone1): return BoundZone(resource_client, data=zone1) def test_init(self, resource_client: ZonesClient, bound_model: BoundZone): @@ -898,13 +920,23 @@ def test_init(self, resource_client: ZonesClient, bound_model: BoundZone): assert o.registrar == "hetzner" -class TestBoundZoneRRSet: +class TestBoundZoneRRSet(BoundModelTestCase): + methods = [ + BoundZoneRRSet.update_rrset, + BoundZoneRRSet.delete_rrset, + BoundZoneRRSet.change_rrset_protection, + BoundZoneRRSet.change_rrset_ttl, + BoundZoneRRSet.add_rrset_records, + BoundZoneRRSet.remove_rrset_records, + BoundZoneRRSet.set_rrset_records, + ] + @pytest.fixture() def resource_client(self, client: Client): return client.zones @pytest.fixture() - def bound_model(self, resource_client, zone_rrset1): + def bound_model(self, resource_client: ZonesClient, zone_rrset1): return BoundZoneRRSet(resource_client, data=zone_rrset1) def test_init(self, resource_client: ZonesClient, bound_model: BoundZoneRRSet): From 03ae506b01071c0350df25d9f433958f4d8a23f6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:14:51 +0200 Subject: [PATCH 115/213] chore(deps): update pre-commit hook pycqa/isort to v7 (#572) 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 | |---|---|---|---| | [pycqa/isort](https://redirect.github.com/pycqa/isort) | repository | major | `6.1.0` -> `7.0.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
pycqa/isort (pycqa/isort) ### [`v7.0.0`](https://redirect.github.com/PyCQA/isort/releases/tag/7.0.0) [Compare Source](https://redirect.github.com/pycqa/isort/compare/6.1.0...7.0.0) #### Changes #### :boom: Breaking Changes - Drop support for Python 3.9 ([#​2430](https://redirect.github.com/pycqa/isort/issues/2430)) [@​DanielNoord](https://redirect.github.com/DanielNoord) #### :rocket: Features - Show absolute paths in skipped file messages ([#​2416](https://redirect.github.com/pycqa/isort/issues/2416)) [@​pranlawate](https://redirect.github.com/pranlawate) #### :beetle: Fixes - Some fixes for Python 3.14 ([#​2433](https://redirect.github.com/pycqa/isort/issues/2433)) [@​DanielNoord](https://redirect.github.com/DanielNoord) - Test on `3.14` and fix any bugs ([#​2425](https://redirect.github.com/pycqa/isort/issues/2425)) [@​DanielNoord](https://redirect.github.com/DanielNoord) - Update CHANGELOG.md + Fix Formatting and Grammar ([#​2419](https://redirect.github.com/pycqa/isort/issues/2419)) [@​lukbrew25](https://redirect.github.com/lukbrew25) - Fix output of hanging indent for long lines with noqa ([#​2407](https://redirect.github.com/pycqa/isort/issues/2407)) [@​matan1008](https://redirect.github.com/matan1008) #### :construction\_worker: Continuous Integration - Format with `ruff` instead of `black` ([#​2432](https://redirect.github.com/pycqa/isort/issues/2432)) [@​DanielNoord](https://redirect.github.com/DanielNoord) - Target 3.10 for `ruff` ([#​2431](https://redirect.github.com/pycqa/isort/issues/2431)) [@​DanielNoord](https://redirect.github.com/DanielNoord) - Update development dependencies to latest version ([#​2426](https://redirect.github.com/pycqa/isort/issues/2426)) [@​DanielNoord](https://redirect.github.com/DanielNoord) - docs: update pre-commit examples to version 6.1.0 ([#​2413](https://redirect.github.com/pycqa/isort/issues/2413)) [@​pranlawate](https://redirect.github.com/pranlawate) - Small cleanup for developer environment ([#​2418](https://redirect.github.com/pycqa/isort/issues/2418)) [@​DanielNoord](https://redirect.github.com/DanielNoord) #### :package: Dependencies - Bump actions/setup-python from 5 to 6 in the github-actions group ([#​2411](https://redirect.github.com/pycqa/isort/issues/2411)) @​[dependabot\[bot\]](https://redirect.github.com/apps/dependabot)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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 c3c10abd..f42645e4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,7 +35,7 @@ repos: args: [--py39-plus] - repo: https://github.com/pycqa/isort - rev: 6.1.0 + rev: 7.0.0 hooks: - id: isort From 8fba599aaecc5243cbf2a1b6ea9490132e9fd723 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 13 Oct 2025 10:29:26 +0200 Subject: [PATCH 116/213] feat: drop support for python 3.9 (#574) Python 3.9 will be EOL in Oct 2025. https://devguide.python.org/versions/ --- .github/workflows/test.yml | 1 - .gitlab-ci.yml | 1 - .pre-commit-config.yaml | 2 +- hcloud/core/client.py | 3 ++- pyproject.toml | 2 +- setup.py | 3 +-- tests/unit/conftest.py | 3 ++- tox.ini | 3 +-- 8 files changed, 8 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2eee2995..5accd397 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,6 @@ jobs: strategy: matrix: python-version: - - "3.9" - "3.10" - "3.11" - "3.12" diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c6236900..f2cc23b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,7 +27,6 @@ test: parallel: matrix: - python_version: - - "3.9" - "3.10" - "3.11" - "3.12" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f42645e4..3afda835 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -32,7 +32,7 @@ repos: rev: v3.21.0 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/pycqa/isort rev: 7.0.0 diff --git a/hcloud/core/client.py b/hcloud/core/client.py index 07dfaf48..da9901f8 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,7 +1,8 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Callable, ClassVar +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: from .._client import Client, ClientBase diff --git a/pyproject.toml b/pyproject.toml index c1fd705e..b35f889d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pylint.main] -py-version = "3.9" +py-version = "3.10" recursive = true jobs = 0 diff --git a/setup.py b/setup.py index 53f75dde..c71436f6 100644 --- a/setup.py +++ b/setup.py @@ -27,14 +27,13 @@ "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "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.9", + python_requires=">=3.10", install_requires=[ "python-dateutil>=2.7.5", "requests>=2.20", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 4b83b01a..78650e53 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,7 +3,8 @@ from __future__ import annotations import inspect -from typing import Callable, ClassVar, TypedDict +from collections.abc import Callable +from typing import ClassVar, TypedDict from unittest import mock import pytest diff --git a/tox.ini b/tox.ini index 1baff836..813895b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py39, py310, py311, py312, py313, py314 +envlist = py310, py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT @@ -10,7 +10,6 @@ commands = [gh-actions] python = - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 From 4203116035b62984666a3bf6f0a13a6b188c511e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:36:57 +0200 Subject: [PATCH 117/213] chore(deps): update dependency pylint to v4 (#573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [pylint](https://redirect.github.com/pylint-dev/pylint) ([changelog](https://pylint.readthedocs.io/en/latest/whatsnew/3/)) | `>=3,<3.4` -> `>=4,<4.1` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/pylint/4.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/pylint/3.3.9/4.0.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
pylint-dev/pylint (pylint) ### [`v4.0.0`](https://redirect.github.com/pylint-dev/pylint/releases/tag/v4.0.0) [Compare Source](https://redirect.github.com/pylint-dev/pylint/compare/v3.3.9...v4.0.0) - Pylint now supports Python 3.14. - Pylint's inference engine (`astroid`) is now much more precise, understanding implicit booleanness and ternary expressions. (Thanks [@​zenlyj](https://redirect.github.com/zenlyj)!) Consider this example: ```python class Result: errors: dict | None = None result = Result() if result.errors: result.errors[field_key] ##### inference engine understands result.errors cannot be None ##### pylint no longer raises unsubscriptable-object ``` The required `astroid` version is now 4.0.0. See the [astroid changelog](https://pylint.readthedocs.io/projects/astroid/en/latest/changelog.html#what-s-new-in-astroid-4-0-0) for additional fixes, features, and performance improvements applicable to pylint. - Handling of `invalid-name` at the module level was patchy. Now, module-level constants that are reassigned are treated as variables and checked against `--variable-rgx` rather than `--const-rgx`. Module-level lists, sets, and objects can pass against either regex. Here, `LIMIT` is reassigned, so pylint only uses `--variable-rgx`: ```python LIMIT = 500 # [invalid-name] if sometimes: LIMIT = 1 # [invalid-name] ``` If this is undesired, refactor using *exclusive* assignment so that it is evident that this assignment happens only once: ```python if sometimes: LIMIT = 1 else: LIMIT = 500 # exclusive assignment: uses const regex, no warning ``` Lists, sets, and objects still pass against either `const-rgx` or `variable-rgx` even if reassigned, but are no longer completely skipped: ```python MY_LIST = [] my_list = [] My_List = [] # [invalid-name] ``` Remember to adjust the [regexes](https://pylint.readthedocs.io/en/latest/user_guide/messages/convention/invalid-name.html) and [allow lists](https://pylint.readthedocs.io/en/latest/user_guide/configuration/all-options.html#good-names) to your liking. ## Breaking Changes - `invalid-name` now distinguishes module-level constants that are assigned only once from those that are reassigned and now applies `--variable-rgx` to the latter. Values other than literals (lists, sets, objects) can pass against either the constant or variable regexes (e.g. "LOGGER" or "logger" but not "LoGgEr"). Remember that `--good-names` or `--good-names-rgxs` can be provided to explicitly allow good names. Closes [#​3585](https://redirect.github.com/pylint-dev/pylint/issues/3585) - The unused `pylintrc` argument to `PyLinter.__init__()` is deprecated and will be removed. Refs [#​6052](https://redirect.github.com/pylint-dev/pylint/issues/6052) - Commented out code blocks such as `# bar() # TODO: remove dead code` will no longer emit `fixme`. Refs [#​9255](https://redirect.github.com/pylint-dev/pylint/issues/9255) - `pyreverse` `Run` was changed to no longer call `sys.exit()` in its `__init__`. You should now call `Run(args).run()` which will return the exit code instead. Having a class that always raised a `SystemExit` exception was considered a bug. Normal usage of pyreverse through the CLI will not be affected by this change. Refs [#​9689](https://redirect.github.com/pylint-dev/pylint/issues/9689) - The `suggestion-mode` option was removed, as pylint now always emits user-friendly hints instead of false-positive error messages. You should remove it from your conf if it's defined. Refs [#​9962](https://redirect.github.com/pylint-dev/pylint/issues/9962) - The `async.py` checker module has been renamed to `async_checker.py` since `async` is a Python keyword and cannot be imported directly. This allows for better testing and extensibility of the async checker functionality. Refs [#​10071](https://redirect.github.com/pylint-dev/pylint/issues/10071) - The message-id of `continue-in-finally` was changed from `E0116` to `W0136`. The warning is now emitted for every Python version since it will raise a syntax warning in Python 3.14. See [PEP 765 - Disallow return/break/continue that exit a finally block](https://peps.python.org/pep-0765/). Refs [#​10480](https://redirect.github.com/pylint-dev/pylint/issues/10480) - Removed support for `nmp.NaN` alias for `numpy.NaN` being recognized in ':ref:`nan-comparison`'. Use `np` or `numpy` instead. Refs [#​10583](https://redirect.github.com/pylint-dev/pylint/issues/10583) - Version requirement for `isort` has been bumped to >=5.0.0. The internal compatibility for older `isort` versions exposed via `pylint.utils.IsortDriver` has been removed. Refs [#​10637](https://redirect.github.com/pylint-dev/pylint/issues/10637) ## New Features - `comparison-of-constants` now uses the unicode from the ast instead of reformatting from the node's values preventing some bad formatting due to `utf-8` limitation. The message now uses `"` instead of `'` to better work with what the python ast returns. Refs [#​8736](https://redirect.github.com/pylint-dev/pylint/issues/8736) - Enhanced pyreverse to properly distinguish between UML relationship types (association, aggregation, composition) based on object ownership semantics. Type annotations without assignment are now treated as associations, parameter assignments as aggregations, and object instantiation as compositions. Closes [#​9045](https://redirect.github.com/pylint-dev/pylint/issues/9045) Closes [#​9267](https://redirect.github.com/pylint-dev/pylint/issues/9267) - The `fixme` check can now search through docstrings as well as comments, by using `check-fixme-in-docstring = true` in the `[tool.pylint.miscellaneous]` section. Closes [#​9255](https://redirect.github.com/pylint-dev/pylint/issues/9255) - The `use-implicit-booleaness-not-x` checks now distinguish between comparisons used in boolean contexts and those that are not, enabling them to provide more accurate refactoring suggestions. Closes [#​9353](https://redirect.github.com/pylint-dev/pylint/issues/9353) - The verbose option now outputs the filenames of the files that have been checked. Previously, it only included the number of checked and skipped files. Closes [#​9357](https://redirect.github.com/pylint-dev/pylint/issues/9357) - colorized reporter now colorizes messages/categories that have been configured as `fail-on` in red inverse. This makes it easier to quickly find the errors that are causing pylint CI job failures. Closes [#​9898](https://redirect.github.com/pylint-dev/pylint/issues/9898) - Enhanced support for [@​property](https://redirect.github.com/property) decorator in pyreverse to correctly display return types of annotated properties when generating class diagrams. Closes [#​10057](https://redirect.github.com/pylint-dev/pylint/issues/10057) - Add --max-depth option to pyreverse to control diagram complexity. A depth of 0 shows only top-level packages, 1 shows one level of subpackages, etc. This helps manage visualization of large codebases by limiting the depth of displayed packages and classes. Refs [#​10077](https://redirect.github.com/pylint-dev/pylint/issues/10077) - Handle deferred evaluation of annotations in Python 3.14. Closes [#​10149](https://redirect.github.com/pylint-dev/pylint/issues/10149) - Enhanced pyreverse to properly detect aggregations for comprehensions (list, dict, set, generator). Closes [#​10236](https://redirect.github.com/pylint-dev/pylint/issues/10236) - `pyreverse`: add support for colorized output when using output format `mmd` (MermaidJS) and `html`. Closes [#​10242](https://redirect.github.com/pylint-dev/pylint/issues/10242) - pypy 3.11 is now officially supported. Refs [#​10287](https://redirect.github.com/pylint-dev/pylint/issues/10287) - Add support for Python 3.14. Refs [#​10467](https://redirect.github.com/pylint-dev/pylint/issues/10467) - Add naming styles for `ParamSpec` and `TypeVarTuple` that align with the `TypeVar` style. Refs [#​10541](https://redirect.github.com/pylint-dev/pylint/issues/10541) ## New Checks - Add `match-statements` checker and the following message: `bare-name-capture-pattern`. This will emit an error message when a name capture pattern is used in a match statement which would make the remaining patterns unreachable. This code is a SyntaxError at runtime. Closes [#​7128](https://redirect.github.com/pylint-dev/pylint/issues/7128) - Add new check `async-context-manager-with-regular-with` to detect async context managers used with regular `with` statements instead of `async with`. Refs [#​10408](https://redirect.github.com/pylint-dev/pylint/issues/10408) - Add `break-in-finally` warning. Using `break` inside the `finally` clause will raise a syntax warning in Python 3.14. See `PEP 765 - Disallow return/break/continue that exit a finally block `\_. Refs [#​10480](https://redirect.github.com/pylint-dev/pylint/issues/10480) - Add new checks for invalid uses of class patterns in :keyword:`match`. - :ref:`invalid-match-args-definition` is emitted if :py:data:`object.__match_args__` isn't a tuple of strings. - :ref:`too-many-positional-sub-patterns` if there are more positional sub-patterns than specified in :py:data:`object.__match_args__`. - :ref:`multiple-class-sub-patterns` if there are multiple sub-patterns for the same attribute. Refs [#​10559](https://redirect.github.com/pylint-dev/pylint/issues/10559) - Add additional checks for suboptimal uses of class patterns in :keyword:`match`. - :ref:`match-class-bind-self` is emitted if a name is bound to `self` instead of using an `as` pattern. - :ref:`match-class-positional-attributes` is emitted if a class pattern has positional attributes when keywords could be used. Refs [#​10586](https://redirect.github.com/pylint-dev/pylint/issues/10586) - Add a `consider-math-not-float` message. `float("nan")` and `float("inf")` are slower than their counterpart `math.inf` and `math.nan` by a factor of 4 (notwithstanding the initial import of math) and they are also not well typed when using mypy. This check also catches typos in float calls as a side effect. The :ref:`pylint.extensions.code_style` need to be activated for this check to work. Refs [#​10621](https://redirect.github.com/pylint-dev/pylint/issues/10621) ## False Positives Fixed - Fix a false positive for `used-before-assignment` when a variable defined under an `if` and via a named expression (walrus operator) is used later when guarded under the same `if` test. Closes [#​10061](https://redirect.github.com/pylint-dev/pylint/issues/10061) - Fix :ref:`no-name-in-module` for members of `concurrent.futures` with Python 3.14. Closes [#​10632](https://redirect.github.com/pylint-dev/pylint/issues/10632) ## False Negatives Fixed - Fix false negative for `used-before-assignment` when a `TYPE_CHECKING` import is used as a type annotation prior to erroneous usage. Refs [#​8893](https://redirect.github.com/pylint-dev/pylint/issues/8893) - Match cases are now counted as edges in the McCabe graph and will increase the complexity accordingly. Refs [#​9667](https://redirect.github.com/pylint-dev/pylint/issues/9667) - Check module-level constants with type annotations for `invalid-name`. Remember to adjust `const-naming-style` or `const-rgx` to your liking. Closes [#​9770](https://redirect.github.com/pylint-dev/pylint/issues/9770) - Fix false negative where function-redefined (E0102) was not reported for functions with a leading underscore. Closes [#​9894](https://redirect.github.com/pylint-dev/pylint/issues/9894) - We now raise a `logging-too-few-args` for format string with no interpolation arguments at all (i.e. for something like `logging.debug("Awaiting process %s")` or `logging.debug("Awaiting process {pid}")`). Previously we did not raise for such case. Closes [#​9999](https://redirect.github.com/pylint-dev/pylint/issues/9999) - Fix false negative for `used-before-assignment` when a function is defined inside a `TYPE_CHECKING` guard block and used later. Closes [#​10028](https://redirect.github.com/pylint-dev/pylint/issues/10028) - Fix a false negative for `possibly-used-before-assignment` when a variable is conditionally defined and later assigned to a type-annotated variable. Closes [#​10421](https://redirect.github.com/pylint-dev/pylint/issues/10421) - Fix false negative for `deprecated-module` when a `__import__` method is used instead of `import` sentence. Refs [#​10453](https://redirect.github.com/pylint-dev/pylint/issues/10453) - Count match cases for `too-many-branches` check. Refs [#​10542](https://redirect.github.com/pylint-dev/pylint/issues/10542) - Fix false-negative where :ref:`unused-import` was not reported for names referenced in a preceding `global` statement. Refs [#​10633](https://redirect.github.com/pylint-dev/pylint/issues/10633) ## Other Bug Fixes - When displaying unicode with surrogates (or other potential `UnicodeEncodeError`), pylint will now display a '?' character (using `encode(encoding="utf-8", errors="replace")`) instead of crashing. The functional tests classes are also updated to handle this case. Closes [#​8736](https://redirect.github.com/pylint-dev/pylint/issues/8736) - Fixed unidiomatic-typecheck only checking left-hand side. Closes [#​10217](https://redirect.github.com/pylint-dev/pylint/issues/10217) - Fix a crash caused by malformed format strings when using `.format` with keyword arguments. Closes [#​10282](https://redirect.github.com/pylint-dev/pylint/issues/10282) - Fix false positive `inconsistent-return-statements` when using `quit()` or `exit()` functions. Closes [#​10508](https://redirect.github.com/pylint-dev/pylint/issues/10508) - Fix a crash in :ref:`nested-min-max` when using `builtins.min` or `builtins.max` instead of `min` or `max` directly. Closes [#​10626](https://redirect.github.com/pylint-dev/pylint/issues/10626) - Fixed a crash in :ref:`unnecessary-dict-index-lookup` when the index of an enumerated list was deleted inside a for loop. Closes [#​10627](https://redirect.github.com/pylint-dev/pylint/issues/10627) ## Other Changes - Remove support for launching pylint with Python 3.9. Code that supports Python 3.9 can still be linted with the `--py-version=3.9` setting. Refs [#​10405](https://redirect.github.com/pylint-dev/pylint/issues/10405) ## Internal Changes - Modified test framework to allow for different test output for different Python versions. Refs [#​10382](https://redirect.github.com/pylint-dev/pylint/issues/10382)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c71436f6..7b11d3e3 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ ], "test": [ "coverage>=7.10,<7.11", - "pylint>=3,<3.4", + "pylint>=4,<4.1", "pytest>=8,<8.5", "pytest-cov>=7,<7.1", "mypy>=1.18,<1.19", From c6aadce64c1dc7fac0f3ba9e26e83a8bf105a578 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:53:51 +0200 Subject: [PATCH 118/213] chore(main): release v2.9.0 (#570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - support python 3.14 (#566) - drop support for python 3.9 (#574) ---

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 | 7 +++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418422f3..f28f7b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v2.9.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.9.0) + +### Features + +- support python 3.14 (#566) +- drop support for python 3.9 (#574) + ## [v2.8.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.8.0) ### DNS API Beta diff --git a/hcloud/_version.py b/hcloud/_version.py index e9a32abd..b320f7b4 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.8.0" # x-releaser-pleaser-version +__version__ = "2.9.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 7b11d3e3..f40d4dfd 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.8.0", # x-releaser-pleaser-version + version="2.9.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From dfae06893650f78244b08b61971336fc219db454 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:51:03 +0200 Subject: [PATCH 119/213] chore(deps): update dependency coverage to >=7.11,<7.12 (#575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [coverage](https://redirect.github.com/nedbat/coveragepy) | `>=7.10,<7.11` -> `>=7.11,<7.12` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/coverage/7.11.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/coverage/7.10.7/7.11.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
nedbat/coveragepy (coverage) ### [`v7.11.0`](https://redirect.github.com/nedbat/coveragepy/blob/HEAD/CHANGES.rst#Version-7110--2025-10-15) [Compare Source](https://redirect.github.com/nedbat/coveragepy/compare/7.10.7...7.11.0) - Dropped support for Python 3.9, declared support for Python 3.15 alpha. .. \_changes\_7-10-7:
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f40d4dfd..0b52af91 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.10,<7.11", + "coverage>=7.11,<7.12", "pylint>=4,<4.1", "pytest>=8,<8.5", "pytest-cov>=7,<7.1", From 56997a1a869933b2ed1f05b202f903873fb3efb7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:39:07 +0100 Subject: [PATCH 120/213] chore(deps): update github artifact actions (major) (#576) 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 | |---|---|---|---| | [actions/download-artifact](https://redirect.github.com/actions/download-artifact) | action | major | `v5` -> `v6` | | [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) | action | major | `v4` -> `v5` | --- ### Release Notes
actions/download-artifact (actions/download-artifact) ### [`v6`](https://redirect.github.com/actions/download-artifact/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/download-artifact/compare/v5...v6)
actions/upload-artifact (actions/upload-artifact) ### [`v5`](https://redirect.github.com/actions/upload-artifact/compare/v4...v5) [Compare Source](https://redirect.github.com/actions/upload-artifact/compare/v4...v5)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] 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> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c65cc60f..70733949 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@v4 + uses: actions/upload-artifact@v5 with: name: python-packages path: dist/ @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v6 with: name: python-packages path: dist/ From d7c9ff7924c5b1a5e1a4008a02e0e66e0651537d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Tue, 28 Oct 2025 09:53:14 +0100 Subject: [PATCH 121/213] docs: update deprecated server types (#577) The CX Gen 2 and CPX Gen 1 types are deprecated and will be removed from the API at the end of the year. This replaces all usages in our docs so users do not have to figure it out themselves. I kept the existing unit tests. --- README.md | 2 +- examples/create_server.py | 2 +- examples/get_server_metrics.py | 2 +- examples/usage_oop.py | 4 ++-- examples/usage_procedurale.py | 6 +++--- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 07666cbd..18876469 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here # Create a server named my-server response = client.servers.create( name="my-server", - server_type=ServerType(name="cx22"), + server_type=ServerType(name="cx23"), image=Image(name="ubuntu-22.04"), ) server = response.server diff --git a/examples/create_server.py b/examples/create_server.py index 81266caf..b9b7399c 100644 --- a/examples/create_server.py +++ b/examples/create_server.py @@ -15,7 +15,7 @@ response = client.servers.create( name="my-server", - server_type=ServerType(name="cx22"), + server_type=ServerType(name="cx23"), image=Image(name="ubuntu-24.04"), ) server = response.server diff --git a/examples/get_server_metrics.py b/examples/get_server_metrics.py index 55f6b9ab..c3023e31 100644 --- a/examples/get_server_metrics.py +++ b/examples/get_server_metrics.py @@ -18,7 +18,7 @@ if server is None: response = client.servers.create( name="my-server", - server_type=ServerType(name="cx22"), + server_type=ServerType(name="cx23"), image=Image(name="ubuntu-24.04"), ) server = response.server diff --git a/examples/usage_oop.py b/examples/usage_oop.py index 8749da47..4b677886 100644 --- a/examples/usage_oop.py +++ b/examples/usage_oop.py @@ -17,11 +17,11 @@ # Create 2 servers # Create 2 servers response1 = client.servers.create( - "Server1", server_type=ServerType(name="cx22"), image=Image(id=4711) + "Server1", server_type=ServerType(name="cx23"), image=Image(id=4711) ) response2 = client.servers.create( - "Server2", server_type=ServerType(name="cx22"), image=Image(id=4711) + "Server2", server_type=ServerType(name="cx23"), image=Image(id=4711) ) # Get all servers server1 = response1.server diff --git a/examples/usage_procedurale.py b/examples/usage_procedurale.py index 228f6821..50d853ba 100644 --- a/examples/usage_procedurale.py +++ b/examples/usage_procedurale.py @@ -17,11 +17,11 @@ # Create 2 servers response1 = client.servers.create( - name="Server1", server_type=ServerType(name="cx22"), image=Image(id=4711) + name="Server1", server_type=ServerType(name="cx23"), image=Image(id=4711) ) response2 = client.servers.create( - "Server2", server_type=ServerType(name="cx22"), image=Image(id=4711) + "Server2", server_type=ServerType(name="cx23"), image=Image(id=4711) ) server1 = response1.server @@ -64,7 +64,7 @@ # Create one more server and attach 2 volumes to it client.servers.create( "Server3", - server_type=ServerType(name="cx22"), + server_type=ServerType(name="cx23"), image=Image(id=4711), volumes=[Volume(id=221), Volume(id=222)], ) From 9f3e282bd947e2abffa7c0733cf49b797d78e8b9 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 31 Oct 2025 12:32:46 +0100 Subject: [PATCH 122/213] feat(exp): add zone format txt record helper (#578) Add a helper function to format a TXT record before sending it to the API. --- hcloud/exp/__init__.py | 4 ++++ hcloud/exp/zone.py | 35 +++++++++++++++++++++++++++++++++++ tests/unit/exp/__init__.py | 0 tests/unit/exp/test_zone.py | 37 +++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) create mode 100644 hcloud/exp/__init__.py create mode 100644 hcloud/exp/zone.py create mode 100644 tests/unit/exp/__init__.py create mode 100644 tests/unit/exp/test_zone.py diff --git a/hcloud/exp/__init__.py b/hcloud/exp/__init__.py new file mode 100644 index 00000000..1f470551 --- /dev/null +++ b/hcloud/exp/__init__.py @@ -0,0 +1,4 @@ +""" +The `exp` module is a namespace that holds experimental features for the `hcloud-python` +library, breaking changes may occur within minor releases. +""" diff --git a/hcloud/exp/zone.py b/hcloud/exp/zone.py new file mode 100644 index 00000000..08601286 --- /dev/null +++ b/hcloud/exp/zone.py @@ -0,0 +1,35 @@ +""" +The `exp.zone` module is a namespace that holds experimental features for the `hcloud-python` +library, breaking changes may occur within minor releases. +""" + +from __future__ import annotations + + +def is_txt_record_quoted(value: str) -> bool: + """ + Check whether a TXT record is already quoted. + + - hello world => false + - "hello world" => true + """ + return value.startswith('"') and value.endswith('"') + + +def format_txt_record(value: str) -> str: + """ + Format a TXT record by splitting it in quoted strings of 255 characters. + Existing quotes will be escaped. + + - hello world => "hello world" + - hello "world" => "hello \"world\"" + """ + value = value.replace('"', '\\"') + + parts = [] + for start in range(0, len(value), 255): + end = min(start + 255, len(value)) + parts.append('"' + value[start:end] + '"') + value = " ".join(parts) + + return value diff --git a/tests/unit/exp/__init__.py b/tests/unit/exp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/exp/test_zone.py b/tests/unit/exp/test_zone.py new file mode 100644 index 00000000..b595111d --- /dev/null +++ b/tests/unit/exp/test_zone.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest + +from hcloud.exp.zone import format_txt_record, is_txt_record_quoted + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("hello world", False), + ('"hello world', False), + ('"hello world"', True), + ], +) +def test_is_txt_record_quoted(value: str, expected: bool): + assert is_txt_record_quoted(value) == expected + + +manyA = "a" * 255 +someB = "b" * 10 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("", ""), + ('""', '"\\"\\""'), + ("hello world", '"hello world"'), + ("hello\nworld", '"hello\nworld"'), + ('hello "world"', '"hello \\"world\\""'), + ('hello "world', '"hello \\"world"'), + (manyA + someB, f'"{manyA}" "{someB}"'), + ], +) +def test_format_txt_record(value: str, expected: str): + assert format_txt_record(value) == expected From 9d19adeb758aa9dbb5718e1e6e086490033d67e3 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 7 Nov 2025 14:40:36 +0100 Subject: [PATCH 123/213] feat: add server and load balancer `private_net_for` helper method (#580) Adds 2 small helper to get the private network attachment information from a server or a load balancer. --- hcloud/load_balancers/domain.py | 12 +++++- hcloud/servers/domain.py | 12 +++++- tests/unit/load_balancers/test_domain.py | 33 +++++++++++++++++ tests/unit/servers/test_domain.py | 47 ++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 6a24547f..fbedf650 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -13,7 +13,7 @@ from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation from ..metrics import Metrics - from ..networks import BoundNetwork + from ..networks import BoundNetwork, Network from ..servers import BoundServer from .client import BoundLoadBalancer @@ -107,6 +107,16 @@ def __init__( self.ingoing_traffic = ingoing_traffic self.included_traffic = included_traffic + def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: + """ + Returns the load balancer's network attachment information in the given Network, + and None if no attachment was found. + """ + for o in self.private_net or []: + if o.network.id == network.id: + return o + return None + class LoadBalancerService(BaseDomain): """LoadBalancerService Domain diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index 368464c4..df9c0a43 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -14,7 +14,7 @@ from ..images import BoundImage from ..isos import BoundIso from ..metrics import Metrics - from ..networks import BoundNetwork + from ..networks import BoundNetwork, Network from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP, PrimaryIP from ..server_types import BoundServerType @@ -154,6 +154,16 @@ def __init__( self.primary_disk_size = primary_disk_size self.placement_group = placement_group + def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: + """ + Returns the server's network attachment information in the given Network, + and None if no attachment was found. + """ + for o in self.private_net or []: + if o.network.id == network.id: + return o + return None + class CreateServerResponse(BaseDomain): """Create Server Response Domain diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 37d1b981..8de47d4d 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -2,10 +2,12 @@ import datetime from datetime import timezone +from unittest import mock import pytest from hcloud.load_balancers import ( + BoundLoadBalancer, IPv4Address, IPv6Network, LoadBalancer, @@ -21,6 +23,7 @@ PrivateNet, PublicNetwork, ) +from hcloud.networks import Network @pytest.mark.parametrize( @@ -56,3 +59,33 @@ class TestLoadBalancers: def test_created_is_datetime(self): lb = LoadBalancer(id=1, created="2016-01-30T23:50+00:00") assert lb.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + load_balancer = LoadBalancer( + id=42, + private_net=[ + PrivateNet(network=network1, ip="127.0.0.1"), + PrivateNet(network=network2, ip="127.0.0.1"), + ], + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None + + load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + {"network": 1, "ip": "127.0.0.1"}, + {"network": 2, "ip": "127.0.0.1"}, + ], + }, + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None diff --git a/tests/unit/servers/test_domain.py b/tests/unit/servers/test_domain.py index fdc05335..9048501f 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -2,10 +2,13 @@ import datetime from datetime import timezone +from unittest import mock import pytest +from hcloud.networks import Network from hcloud.servers import ( + BoundServer, IPv4Address, IPv6Network, PrivateNet, @@ -46,3 +49,47 @@ def test_created_is_datetime(self): assert server.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + server = Server( + id=42, + private_net=[ + PrivateNet( + network=network1, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + PrivateNet( + network=network2, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + ], + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None + + server = BoundServer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + { + "network": 1, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + { + "network": 2, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + ], + }, + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None From 79dfbbe8c9f4906427309939b13f27f29c688709 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:42:20 +0100 Subject: [PATCH 124/213] chore(main): release v2.10.0 (#579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - **exp**: add zone format txt record helper (#578) - add server and load balancer `private_net_for` helper method (#580) ---

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 | 7 +++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f28f7b38..a945a797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v2.10.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.10.0) + +### Features + +- **exp**: add zone format txt record helper (#578) +- add server and load balancer `private_net_for` helper method (#580) + ## [v2.9.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.9.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index b320f7b4..fc2e4180 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.9.0" # x-releaser-pleaser-version +__version__ = "2.10.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 0b52af91..4c3e3667 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.9.0", # x-releaser-pleaser-version + version="2.10.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From d252f2a6145e2eada125c0254fb8142f5b8faca3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:17:46 +0100 Subject: [PATCH 125/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.21.1 (#583) --- .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 3afda835..41ae3c97 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.0 + rev: v3.21.1 hooks: - id: pyupgrade args: [--py310-plus] From 2a8dc49d7444800d9aafe24def7d830475c292f6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 10:20:24 +0100 Subject: [PATCH 126/213] chore(deps): update dependency pytest to v9 (#582) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4c3e3667..b6d08831 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ "test": [ "coverage>=7.11,<7.12", "pylint>=4,<4.1", - "pytest>=8,<8.5", + "pytest>=9,<9.1", "pytest-cov>=7,<7.1", "mypy>=1.18,<1.19", "types-python-dateutil", From 19d989b14207b0ac6c91fe639703cde46a034947 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Mon, 10 Nov 2025 11:27:49 +0100 Subject: [PATCH 127/213] feat: DNS support is now generally available (#581) See https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga for more details. --- hcloud/zones/client.py | 208 ----------------------------------------- 1 file changed, 208 deletions(-) diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index baa841be..2da74d4b 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -59,10 +59,6 @@ def update( See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone :param labels: User-defined labels (key/value pairs) for the Resource. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.update(self, labels=labels) @@ -71,10 +67,6 @@ def delete(self) -> DeleteZoneResponse: Deletes the Zone. See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.delete(self) @@ -83,10 +75,6 @@ def export_zonefile(self) -> ExportZonefileResponse: Returns a generated Zone file in BIND (RFC 1034/1035) format. See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.export_zonefile(self) @@ -107,10 +95,6 @@ def get_actions_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.get_actions_list( self, @@ -133,10 +117,6 @@ def get_actions( :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.get_actions( self, @@ -154,10 +134,6 @@ def import_zonefile( See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file :param zonefile: Zone file to import. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.import_zonefile(self, zonefile=zonefile) @@ -172,10 +148,6 @@ def change_protection( See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection :param delete: Prevents the Zone from being deleted. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_protection(self, delete=delete) @@ -189,10 +161,6 @@ def change_ttl( See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl :param ttl: Default Time To Live (TTL) of the Zone. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_ttl(self, ttl=ttl) @@ -206,10 +174,6 @@ def change_primary_nameservers( See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers :param primary_nameservers: Primary nameservers of the Zone. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_primary_nameservers( self, @@ -228,10 +192,6 @@ def get_rrset( :param name: Name of the RRSet. :param type: Type of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.get_rrset(self, name=name, type=type) @@ -256,10 +216,6 @@ def get_rrset_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.get_rrset_list( self, @@ -288,10 +244,6 @@ def get_rrset_all( :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.get_rrset_all( self, @@ -320,10 +272,6 @@ def create_rrset( :param ttl: Time To Live (TTL) of the RRSet. :param labels: User-defined labels (key/value pairs) for the Resource. :param records: Records of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.create_rrset( self, @@ -347,10 +295,6 @@ def update_rrset( :param rrset: RRSet to update. :param labels: User-defined labels (key/value pairs) for the Resource. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.update_rrset(rrset=rrset, labels=labels) @@ -364,10 +308,6 @@ def delete_rrset( See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset :param rrset: RRSet to delete. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.delete_rrset(rrset=rrset) @@ -384,10 +324,6 @@ def change_rrset_protection( :param rrset: RRSet to update. :param change: Prevent the Zone from being changed (deletion and updates). - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_rrset_protection(rrset=rrset, change=change) @@ -403,10 +339,6 @@ def change_rrset_ttl( :param rrset: RRSet to update. :param change: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_rrset_ttl(rrset=rrset, ttl=ttl) @@ -424,10 +356,6 @@ def add_rrset_records( :param rrset: RRSet to update. :param records: Records to add to the RRSet. :param ttl: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.add_rrset_records(rrset=rrset, records=records, ttl=ttl) @@ -443,10 +371,6 @@ def remove_rrset_records( :param rrset: RRSet to update. :param records: Records to remove from the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.remove_rrset_records(rrset=rrset, records=records) @@ -462,10 +386,6 @@ def set_rrset_records( :param rrset: RRSet to update. :param records: Records to set in the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.set_rrset_records(rrset=rrset, records=records) @@ -497,10 +417,6 @@ def update_rrset( See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset :param labels: User-defined labels (key/value pairs) for the Resource. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.update_rrset(self, labels=labels) @@ -511,10 +427,6 @@ def delete_rrset( Deletes the ZoneRRSet. See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.delete_rrset(self) @@ -529,10 +441,6 @@ def change_rrset_protection( See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection :param change: Prevent the Zone from being changed (deletion and updates). - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_rrset_protection(self, change=change) @@ -546,10 +454,6 @@ def change_rrset_ttl( See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl :param change: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.change_rrset_ttl(self, ttl=ttl) @@ -565,10 +469,6 @@ def add_rrset_records( :param records: Records to add to the RRSet. :param ttl: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.add_rrset_records(self, records=records, ttl=ttl) @@ -582,10 +482,6 @@ def remove_rrset_records( See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset :param records: Records to remove from the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.remove_rrset_records(self, records=records) @@ -599,10 +495,6 @@ def set_rrset_records( See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset :param records: Records to set in the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._client.set_rrset_records(self, records=records) @@ -622,10 +514,6 @@ class ZonesClient(ResourceClientBase): ZoneClient is a client for the Zone (DNS) API. See https://docs.hetzner.cloud/reference/cloud#zones and https://docs.hetzner.cloud/reference/cloud#zone-rrsets. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ _base_url = "/zones" @@ -647,10 +535,6 @@ def get(self, id_or_name: int | str) -> BoundZone: See https://docs.hetzner.cloud/reference/cloud#zones-get-a-zone :param id_or_name: ID or Name of the Zone. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ response = self._client.request( method="GET", @@ -679,10 +563,6 @@ def get_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ params: dict[str, Any] = {} if name is not None: @@ -725,10 +605,6 @@ def get_all( :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._iter_pages( self.get_list, @@ -763,10 +639,6 @@ def create( :param primary_nameservers: Primary nameservers of the Zone. :param rrsets: RRSets to be added to the Zone. :param zonefile: Zone file to import. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = { "name": name, @@ -807,10 +679,6 @@ def update( :param zone: Zone to update. :param labels: User-defined labels (key/value pairs) for the Resource. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = {} if labels is not None: @@ -833,10 +701,6 @@ def delete( See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone :param zone: Zone to delete. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ response = self._client.request( method="DELETE", @@ -857,10 +721,6 @@ def export_zonefile( See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file :param zone: Zone to export the zone file from. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ response = self._client.request( method="GET", @@ -887,10 +747,6 @@ def get_actions_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ params: dict[str, Any] = {} if status is not None: @@ -927,10 +783,6 @@ def get_actions( :param zone: Zone to fetch the Actions from. :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._iter_pages( self.get_actions_list, @@ -951,10 +803,6 @@ def import_zonefile( :param zone: Zone to import the zone file into. :param zonefile: Zone file to import. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = { "zonefile": zonefile, @@ -980,10 +828,6 @@ def change_protection( :param zone: Zone to update. :param delete: Prevents the Zone from being deleted. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = {} if delete is not None: @@ -1008,10 +852,6 @@ def change_ttl( :param zone: Zone to update. :param ttl: Default Time To Live (TTL) of the Zone. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = { "ttl": ttl, @@ -1036,10 +876,6 @@ def change_primary_nameservers( :param zone: Zone to update. :param primary_nameservers: Primary nameservers of the Zone. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = { "primary_nameservers": [o.to_payload() for o in primary_nameservers], @@ -1066,10 +902,6 @@ def get_rrset( :param zone: Zone to fetch the RRSet from. :param name: Name of the RRSet. :param type: Type of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ response = self._client.request( method="GET", @@ -1100,10 +932,6 @@ def get_rrset_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ params: dict[str, Any] = {} if name is not None: @@ -1148,10 +976,6 @@ def get_rrset_all( :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ return self._iter_pages( self.get_rrset_list, @@ -1183,10 +1007,6 @@ def create_rrset( :param ttl: Time To Live (TTL) of the RRSet. :param labels: User-defined labels (key/value pairs) for the Resource. :param records: Records of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ data: dict[str, Any] = { "name": name, @@ -1222,10 +1042,6 @@ def update_rrset( :param rrset: RRSet to update. :param labels: User-defined labels (key/value pairs) for the Resource. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1251,10 +1067,6 @@ def delete_rrset( See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset :param rrset: RRSet to delete. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1280,10 +1092,6 @@ def change_rrset_protection( :param rrset: RRSet to update. :param change: Prevent the Zone from being changed (deletion and updates). - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1311,10 +1119,6 @@ def change_rrset_ttl( :param rrset: RRSet to update. :param change: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1344,10 +1148,6 @@ def add_rrset_records( :param rrset: RRSet to update. :param records: Records to add to the RRSet. :param ttl: Time To Live (TTL) of the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1377,10 +1177,6 @@ def remove_rrset_records( :param rrset: RRSet to update. :param records: Records to remove from the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") @@ -1408,10 +1204,6 @@ def set_rrset_records( :param rrset: RRSet to update. :param records: Records to set in the RRSet. - - Experimental: - DNS API is in beta, breaking changes may occur within minor releases. - See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. """ if rrset.zone is None: raise ValueError("rrset zone property is none") From 102559e39ff92ea0e4d266cabe6ecc35367a27f3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:28:07 +0100 Subject: [PATCH 128/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v25.11.0 (#584) 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 | `25.9.0` -> `25.11.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) ### [`v25.11.0`](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.0) [Compare Source](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.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 41ae3c97..fe5b40d9 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: 25.9.0 + rev: 25.11.0 hooks: - id: black From 8e32b044b3a291308c1c3deafba328e8b50e1c8f Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:42:34 +0100 Subject: [PATCH 129/213] chore(main): release v2.11.0 (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### DNS API is now generally available The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process). See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for more details. ### Features - DNS support is now generally available (#581) ---

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 ### DNS API is now generally available The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process). See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) 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 a945a797..7a238bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [v2.11.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.0) + +### DNS API is now generally available + +The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. + +To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process). + +See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for more details. + +### Features + +- DNS support is now generally available (#581) + ## [v2.10.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.10.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index fc2e4180..e7c314f9 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.10.0" # x-releaser-pleaser-version +__version__ = "2.11.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index b6d08831..2945d2f4 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.10.0", # x-releaser-pleaser-version + version="2.11.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 28cb4b8f7c519605959606235015c8ab09ce081d Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 13 Nov 2025 14:52:33 +0100 Subject: [PATCH 130/213] refactor: pass list function to _get_first_by helper (#588) This will be useful for coming Storage Box sub resources, which may use different list functions. --- hcloud/certificates/client.py | 2 +- hcloud/core/client.py | 6 ++---- hcloud/datacenters/client.py | 2 +- hcloud/firewalls/client.py | 2 +- hcloud/floating_ips/client.py | 2 +- hcloud/images/client.py | 3 ++- hcloud/isos/client.py | 2 +- hcloud/load_balancer_types/client.py | 2 +- hcloud/load_balancers/client.py | 2 +- hcloud/locations/client.py | 2 +- hcloud/networks/client.py | 2 +- hcloud/placement_groups/client.py | 2 +- hcloud/primary_ips/client.py | 2 +- hcloud/server_types/client.py | 2 +- hcloud/servers/client.py | 2 +- hcloud/ssh_keys/client.py | 4 ++-- hcloud/volumes/client.py | 2 +- tests/unit/core/test_client.py | 4 ++-- 18 files changed, 22 insertions(+), 23 deletions(-) diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 9404513e..8b2a7ef7 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -202,7 +202,7 @@ def get_by_name(self, name: str) -> BoundCertificate | None: Used to get certificate by name. :return: :class:`BoundCertificate ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/core/client.py b/hcloud/core/client.py index da9901f8..c33b0352 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -46,10 +46,8 @@ def _iter_pages( # type: ignore[no-untyped-def] return results - def _get_first_by(self, **kwargs): # type: ignore[no-untyped-def] - assert hasattr(self, "get_list") - # pylint: disable=no-member - entities, _ = self.get_list(**kwargs) + def _get_first_by(self, list_function: Callable, *args, **kwargs): # type: ignore[no-untyped-def] + entities, _ = list_function(*args, **kwargs) return entities[0] if entities else None diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 880e1c24..ae338a5a 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -115,4 +115,4 @@ def get_by_name(self, name: str) -> BoundDatacenter | None: Used to get datacenter by name. :return: :class:`BoundDatacenter ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 771f3845..a088947a 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -351,7 +351,7 @@ def get_by_name(self, name: str) -> BoundFirewall | None: Used to get Firewall by name. :return: :class:`BoundFirewall ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 15432d1a..ce28be3d 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -293,7 +293,7 @@ def get_by_name(self, name: str) -> BoundFloatingIP | None: Used to get Floating IP by name. :return: :class:`BoundFloatingIP ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/images/client.py b/hcloud/images/client.py index 3933ebe4..b9425b78 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -329,7 +329,7 @@ def get_by_name(self, name: str) -> BoundImage | None: DeprecationWarning, stacklevel=2, ) - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_by_name_and_architecture( self, @@ -349,6 +349,7 @@ def get_by_name_and_architecture( :return: :class:`BoundImage ` """ return self._get_first_by( + self.get_list, name=name, architecture=[architecture], include_deprecated=include_deprecated, diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index f3e86896..d5e44963 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -99,4 +99,4 @@ def get_by_name(self, name: str) -> BoundIso | None: Used to get iso by name. :return: :class:`BoundIso ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 12d0f03e..fbfb08d1 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -81,4 +81,4 @@ def get_by_name(self, name: str) -> BoundLoadBalancerType | None: Used to get Load Balancer type by name. :return: :class:`BoundLoadBalancerType ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 1317b732..2539101b 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -467,7 +467,7 @@ def get_by_name(self, name: str) -> BoundLoadBalancer | None: Used to get Load Balancer by name. :return: :class:`BoundLoadBalancer ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 154ba843..e6f685db 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -76,4 +76,4 @@ def get_by_name(self, name: str) -> BoundLocation | None: Used to get location by name. :return: :class:`BoundLocation ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 7c6846a8..abef2b63 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -252,7 +252,7 @@ def get_by_name(self, name: str) -> BoundNetwork | None: Used to get network by name. :return: :class:`BoundNetwork ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index de61330b..94939341 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -131,7 +131,7 @@ def get_by_name(self, name: str) -> BoundPlacementGroup | None: Used to get Placement Group by name :return: class:`BoundPlacementGroup ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index c6acf6ef..eb5d30d1 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -189,7 +189,7 @@ def get_by_name(self, name: str) -> BoundPrimaryIP | None: Used to get Primary IP by name. :return: :class:`BoundPrimaryIP ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index bfa525c2..db0ef54a 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -99,4 +99,4 @@ def get_by_name(self, name: str) -> BoundServerType | None: Used to get Server type by name. :return: :class:`BoundServerType ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index faeed0be..626c6c36 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -596,7 +596,7 @@ def get_by_name(self, name: str) -> BoundServer | None: Used to get server by name. :return: :class:`BoundServer ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) # pylint: disable=too-many-branches,too-many-locals def create( diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index 4fc541f5..a565469b 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -121,7 +121,7 @@ def get_by_name(self, name: str) -> BoundSSHKey | None: Used to get ssh key by name. :return: :class:`BoundSSHKey ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_by_fingerprint(self, fingerprint: str) -> BoundSSHKey | None: """Get ssh key by fingerprint @@ -130,7 +130,7 @@ def get_by_fingerprint(self, fingerprint: str) -> BoundSSHKey | None: Used to get ssh key by fingerprint. :return: :class:`BoundSSHKey ` """ - return self._get_first_by(fingerprint=fingerprint) + return self._get_first_by(self.get_list, fingerprint=fingerprint) def create( self, diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 0b1b9667..9d1cded6 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -225,7 +225,7 @@ def get_by_name(self, name: str) -> BoundVolume | None: Used to get volume by name. :return: :class:`BoundVolume ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, diff --git a/tests/unit/core/test_client.py b/tests/unit/core/test_client.py index 72e90879..d2ff4250 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -223,7 +223,7 @@ def json_content_function(p): candies_client = client_class_constructor(json_content_function) - result = candies_client._get_first_by(status="sweet") + result = candies_client._get_first_by(candies_client.get_list, status="sweet") assert result == (1, None, "sweet", None) @@ -235,6 +235,6 @@ def json_content_function(p): candies_client = client_class_constructor(json_content_function) - result = candies_client._get_first_by(status="sweet") + result = candies_client._get_first_by(candies_client.get_list, status="sweet") assert result is None From e071a8b9e9e2951ff04cc163fc525d4989c4960c Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 14 Nov 2025 11:24:31 +0100 Subject: [PATCH 131/213] test: run pylint against test files (#592) This should help us write better tests and catch invalid tests. --- Makefile | 1 + tests/unit/actions/test_domain.py | 2 +- tests/unit/certificates/test_domain.py | 2 +- tests/unit/core/test_client.py | 12 ++++++------ tests/unit/datacenters/test_domain.py | 2 +- tests/unit/deprecation/test_domain.py | 2 +- tests/unit/exp/test_zone.py | 6 +++--- tests/unit/firewalls/test_domain.py | 2 +- tests/unit/floating_ips/test_client.py | 4 ---- tests/unit/floating_ips/test_domain.py | 6 +++--- tests/unit/images/test_domain.py | 2 +- tests/unit/isos/test_domain.py | 2 +- tests/unit/load_balancer_types/test_domain.py | 2 +- tests/unit/load_balancers/test_domain.py | 2 +- tests/unit/locations/test_domain.py | 2 +- tests/unit/networks/test_domain.py | 2 +- tests/unit/placement_groups/test_client.py | 4 +--- tests/unit/placement_groups/test_domain.py | 2 +- tests/unit/primary_ips/test_domain.py | 6 +++--- tests/unit/server_types/test_domain.py | 2 +- tests/unit/servers/test_client.py | 1 + tests/unit/servers/test_domain.py | 2 +- tests/unit/ssh_keys/test_domain.py | 6 +++--- tests/unit/volumes/test_domain.py | 2 +- 24 files changed, 36 insertions(+), 40 deletions(-) diff --git a/Makefile b/Makefile index 9a063b01..cd3ea5e9 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ venv: lint: venv venv/bin/pylint hcloud + venv/bin/pylint tests --disable=missing-function-docstring,use-dict-literal,protected-access,redefined-outer-name,unnecessary-dunder-call venv/bin/mypy hcloud test: venv diff --git a/tests/unit/actions/test_domain.py b/tests/unit/actions/test_domain.py index c5efcde5..395af182 100644 --- a/tests/unit/actions/test_domain.py +++ b/tests/unit/actions/test_domain.py @@ -20,7 +20,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestAction: diff --git a/tests/unit/certificates/test_domain.py b/tests/unit/certificates/test_domain.py index d65a61ee..3c3b2517 100644 --- a/tests/unit/certificates/test_domain.py +++ b/tests/unit/certificates/test_domain.py @@ -21,7 +21,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestCertificate: diff --git a/tests/unit/core/test_client.py b/tests/unit/core/test_client.py index d2ff4250..6e9561fd 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -47,7 +47,7 @@ def test_get_non_exists_model_attribute_complete_model( client=client, data={"id": 1, "name": "name", "description": "description"} ) with pytest.raises(AttributeError): - bound_model.content + _ = bound_model.content client.get_by_id.assert_not_called() def test_get_exists_model_attribute_incomplete_model( @@ -77,7 +77,7 @@ def test_get_non_exists_model_attribute_incomplete_model( ): bound_model = bound_model_class(client=client, data={"id": 1}, complete=False) with pytest.raises(AttributeError): - bound_model.content + _ = bound_model.content client.get_by_id.assert_not_called() assert bound_model.complete is False @@ -137,7 +137,7 @@ def get_actions_list(self, status, page=None, per_page=None): def test_iter_pages_no_meta(self, client_class_constructor): json_content = {"candies": [1, 2]} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) @@ -152,7 +152,7 @@ def test_iter_pages_no_next_page(self, client_class_constructor): "meta": {"pagination": {"page": 1, "per_page": 11, "next_page": None}}, } - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) @@ -218,7 +218,7 @@ def json_content_function(p): def test_get_first_by_result_exists(self, client_class_constructor): json_content = {"candies": [1]} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) @@ -230,7 +230,7 @@ def json_content_function(p): def test_get_first_by_result_does_not_exist(self, client_class_constructor): json_content = {"candies": []} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) diff --git a/tests/unit/datacenters/test_domain.py b/tests/unit/datacenters/test_domain.py index 09ed99ed..58666bf6 100644 --- a/tests/unit/datacenters/test_domain.py +++ b/tests/unit/datacenters/test_domain.py @@ -13,4 +13,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/deprecation/test_domain.py b/tests/unit/deprecation/test_domain.py index 35c8473f..4ab020e9 100644 --- a/tests/unit/deprecation/test_domain.py +++ b/tests/unit/deprecation/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/exp/test_zone.py b/tests/unit/exp/test_zone.py index b595111d..f787cd08 100644 --- a/tests/unit/exp/test_zone.py +++ b/tests/unit/exp/test_zone.py @@ -17,8 +17,8 @@ def test_is_txt_record_quoted(value: str, expected: bool): assert is_txt_record_quoted(value) == expected -manyA = "a" * 255 -someB = "b" * 10 +MANY_A = "a" * 255 +SOME_B = "b" * 10 @pytest.mark.parametrize( @@ -30,7 +30,7 @@ def test_is_txt_record_quoted(value: str, expected: bool): ("hello\nworld", '"hello\nworld"'), ('hello "world"', '"hello \\"world\\""'), ('hello "world', '"hello \\"world"'), - (manyA + someB, f'"{manyA}" "{someB}"'), + (MANY_A + SOME_B, f'"{MANY_A}" "{SOME_B}"'), ], ) def test_format_txt_record(value: str, expected: str): diff --git a/tests/unit/firewalls/test_domain.py b/tests/unit/firewalls/test_domain.py index ec303c14..7924fbb7 100644 --- a/tests/unit/firewalls/test_domain.py +++ b/tests/unit/firewalls/test_domain.py @@ -25,7 +25,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestFirewall: diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index 6bc991b9..cc271c66 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -228,14 +228,10 @@ def test_create_with_server( assert bound_floating_ip.description == "Web Frontend" assert action.id == 13 - @pytest.mark.parametrize( - "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] - ) def test_create_with_name( self, request_mock: mock.MagicMock, floating_ips_client: FloatingIPsClient, - server, floating_ip_create_response, ): request_mock.return_value = floating_ip_create_response diff --git a/tests/unit/floating_ips/test_domain.py b/tests/unit/floating_ips/test_domain.py index a16a25b4..98be19e6 100644 --- a/tests/unit/floating_ips/test_domain.py +++ b/tests/unit/floating_ips/test_domain.py @@ -15,12 +15,12 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestFloatingIP: def test_created_is_datetime(self): - floatingIP = FloatingIP(id=1, created="2016-01-30T23:50+00:00") - assert floatingIP.created == datetime.datetime( + floating_ip = FloatingIP(id=1, created="2016-01-30T23:50+00:00") + assert floating_ip.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/images/test_domain.py b/tests/unit/images/test_domain.py index d85c8a18..4ff6c133 100644 --- a/tests/unit/images/test_domain.py +++ b/tests/unit/images/test_domain.py @@ -15,7 +15,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestImage: diff --git a/tests/unit/isos/test_domain.py b/tests/unit/isos/test_domain.py index c056f3b7..f6c72a15 100644 --- a/tests/unit/isos/test_domain.py +++ b/tests/unit/isos/test_domain.py @@ -14,7 +14,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestIso: diff --git a/tests/unit/load_balancer_types/test_domain.py b/tests/unit/load_balancer_types/test_domain.py index d3b8eff7..bbd0129c 100644 --- a/tests/unit/load_balancer_types/test_domain.py +++ b/tests/unit/load_balancer_types/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 8de47d4d..47b081f2 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -52,7 +52,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestLoadBalancers: diff --git a/tests/unit/locations/test_domain.py b/tests/unit/locations/test_domain.py index fd5d710a..387e6264 100644 --- a/tests/unit/locations/test_domain.py +++ b/tests/unit/locations/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/networks/test_domain.py b/tests/unit/networks/test_domain.py index 187de5fb..dc3d3ba7 100644 --- a/tests/unit/networks/test_domain.py +++ b/tests/unit/networks/test_domain.py @@ -17,7 +17,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestNetwork: diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 0c35d41b..fc8cada8 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -68,9 +68,7 @@ def test_get_by_id( request_mock.assert_called_with( method="GET", - url="/placement_groups/{placement_group_id}".format( - placement_group_id=placement_group_response["placement_group"]["id"] - ), + url="/placement_groups/897", ) assert placement_group._client is resource_client diff --git a/tests/unit/placement_groups/test_domain.py b/tests/unit/placement_groups/test_domain.py index f8293fcc..0d25b54f 100644 --- a/tests/unit/placement_groups/test_domain.py +++ b/tests/unit/placement_groups/test_domain.py @@ -15,7 +15,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestPlacementGroup: diff --git a/tests/unit/primary_ips/test_domain.py b/tests/unit/primary_ips/test_domain.py index 5adcf226..c1fadf0a 100644 --- a/tests/unit/primary_ips/test_domain.py +++ b/tests/unit/primary_ips/test_domain.py @@ -15,12 +15,12 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestPrimaryIP: def test_created_is_datetime(self): - primaryIP = PrimaryIP(id=1, created="2016-01-30T23:50+00:00") - assert primaryIP.created == datetime.datetime( + primary_ip = PrimaryIP(id=1, created="2016-01-30T23:50+00:00") + assert primary_ip.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/server_types/test_domain.py b/tests/unit/server_types/test_domain.py index 4d7a28c2..6d449061 100644 --- a/tests/unit/server_types/test_domain.py +++ b/tests/unit/server_types/test_domain.py @@ -12,4 +12,4 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index f3cb490e..ebf1da24 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -68,6 +68,7 @@ def resource_client(self, client: Client): def bound_model(self, resource_client: ServersClient): return BoundServer(resource_client, data=dict(id=14)) + # pylint: disable=too-many-statements def test_init(self, response_full_server): bound_server = BoundServer( client=mock.MagicMock(), data=response_full_server["server"] diff --git a/tests/unit/servers/test_domain.py b/tests/unit/servers/test_domain.py index 9048501f..2a80380c 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -40,7 +40,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestServer: diff --git a/tests/unit/ssh_keys/test_domain.py b/tests/unit/ssh_keys/test_domain.py index e354128e..9521f3a5 100644 --- a/tests/unit/ssh_keys/test_domain.py +++ b/tests/unit/ssh_keys/test_domain.py @@ -15,12 +15,12 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestSSHKey: def test_created_is_datetime(self): - sshKey = SSHKey(id=1, created="2016-01-30T23:50+00:00") - assert sshKey.created == datetime.datetime( + ssh_key = SSHKey(id=1, created="2016-01-30T23:50+00:00") + assert ssh_key.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/volumes/test_domain.py b/tests/unit/volumes/test_domain.py index f69329d9..50d54d43 100644 --- a/tests/unit/volumes/test_domain.py +++ b/tests/unit/volumes/test_domain.py @@ -15,7 +15,7 @@ ], ) def test_eq(value): - assert value == value + assert value.__eq__(value) class TestVolume: From 10af141ae94837c34eedab148dc57c3d87baf151 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 14 Nov 2025 13:49:52 +0100 Subject: [PATCH 132/213] fix: support reloading sub resource bound models (#590) Sub resource bound models cannot be reloaded using `get_by_id`, a custom function must be implemented. --- hcloud/core/client.py | 9 ++++++--- hcloud/zones/client.py | 7 +++++++ tests/unit/conftest.py | 4 ++++ tests/unit/zones/test_client.py | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/hcloud/core/client.py b/hcloud/core/client.py index c33b0352..f0e67a7f 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -103,10 +103,13 @@ def __getattr__(self, name: str): # type: ignore[no-untyped-def] value = getattr(self.data_model, name) return value - def reload(self) -> None: - """Reloads the model and tries to get all data from the APIx""" + def _get_self(self) -> BoundModelBase: assert hasattr(self._client, "get_by_id") - bound_model = self._client.get_by_id(self.data_model.id) + return self._client.get_by_id(self.data_model.id) + + def reload(self) -> None: + """Reloads the model and tries to get all data from the API""" + bound_model = self._get_self() self.data_model = bound_model.data_model self.complete = True diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index 2da74d4b..832d76f7 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -406,6 +406,13 @@ def __init__(self, client: ZonesClient, data: dict, complete: bool = True): super().__init__(client, data, complete) + def _get_self(self) -> BoundZoneRRSet: + return self._client.get_rrset( + self.data_model.zone, + self.data_model.name, + self.data_model.type, + ) + def update_rrset( self, *, diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 78650e53..17c37704 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -188,6 +188,10 @@ def test_method_list(self, bound_model): lambda m: inspect.ismethod(m) and m.__func__ in bound_model.__class__.__dict__.values(), ): + # Ignore private methods + if name.startswith("_"): + continue + # Actions methods are already tested in TestBoundModelActions. if name in ("__init__", "get_actions", "get_actions_list"): continue diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py index e20aaedd..2e966032 100644 --- a/tests/unit/zones/test_client.py +++ b/tests/unit/zones/test_client.py @@ -958,3 +958,25 @@ def test_init(self, resource_client: ZonesClient, bound_model: BoundZoneRRSet): assert isinstance(o.zone, BoundZone) assert o.zone.id == 42 + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_rrset1, + ): + o = BoundZoneRRSet( + resource_client, + data={"id": "www/A", "zone": 42}, + complete=False, + ) + request_mock.return_value = {"rrset": zone_rrset1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/zones/42/rrsets/www/A", + ) + + assert o.labels is not None From f2d4bcb769fbb615d657673931202069e7a2d0b6 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 14 Nov 2025 14:01:36 +0100 Subject: [PATCH 133/213] chore(main): release v2.11.1 (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Bug Fixes - support reloading sub resource bound models (#590) ---

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 7a238bf0..ccf5eb3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.11.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.1) + +### Bug Fixes + +- support reloading sub resource bound models (#590) + ## [v2.11.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.0) ### DNS API is now generally available diff --git a/hcloud/_version.py b/hcloud/_version.py index e7c314f9..25bb8326 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.11.0" # x-releaser-pleaser-version +__version__ = "2.11.1" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 2945d2f4..abd982e6 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.11.0", # x-releaser-pleaser-version + version="2.11.1", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 4639921003942c4f6f2c4d303126ad41cb13fd75 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:49:58 +0100 Subject: [PATCH 134/213] chore(deps): update actions/checkout action to v6 (#598) 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 | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` -> `v6` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 a16ee4da..d0a893a0 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@v5 + - uses: actions/checkout@v6 - name: Setup python uses: actions/setup-python@v6 @@ -25,7 +25,7 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Setup python uses: actions/setup-python@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70733949..385950b8 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@v5 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5accd397..379c1fc6 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@v5 + - uses: actions/checkout@v6 - name: Setup python uses: actions/setup-python@v6 From 79feb48e644a76462b234eb4e34603d7b6bbc993 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:50:12 +0100 Subject: [PATCH 135/213] chore(deps): update pre-commit hook asottile/pyupgrade to v3.21.2 (#596) 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 | |---|---|---|---| | [asottile/pyupgrade](https://redirect.github.com/asottile/pyupgrade) | repository | patch | `v3.21.1` -> `v3.21.2` | 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
asottile/pyupgrade (asottile/pyupgrade) ### [`v3.21.2`](https://redirect.github.com/asottile/pyupgrade/compare/v3.21.1...v3.21.2) [Compare Source](https://redirect.github.com/asottile/pyupgrade/compare/v3.21.1...v3.21.2)
--- ### 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 fe5b40d9..6e21ba29 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.1 + rev: v3.21.2 hooks: - id: pyupgrade args: [--py310-plus] From 1c9bd3aa110c879754f10b799b151615ad2400fe Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 27 Nov 2025 14:50:24 +0100 Subject: [PATCH 136/213] chore(deps): update dependency coverage to >=7.12,<7.13 (#595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | Age | Confidence | |---|---|---|---| | [coverage](https://redirect.github.com/coveragepy/coveragepy) | `>=7.11,<7.12` -> `>=7.12,<7.13` | [![age](https://developer.mend.io/api/mc/badges/age/pypi/coverage/7.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/coverage/7.11.3/7.12.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | --- ### Release Notes
coveragepy/coveragepy (coverage) ### [`v7.12.0`](https://redirect.github.com/coveragepy/coveragepy/blob/HEAD/CHANGES.rst#Version-7120--2025-11-18) [Compare Source](https://redirect.github.com/coveragepy/coveragepy/compare/7.11.3...7.12.0) - The HTML report now shows separate coverage totals for statements and branches, as well as the usual combined coverage percentage. Thanks to Ryuta Otsuka for the `discussion `\_ and the `implementation `\_. - The JSON report now includes separate coverage totals for statements and branches, thanks to `Ryuta Otsuka `\_. - Fix: `except*` clauses were not handled properly under the "sysmon" measurement core, causing KeyError exceptions as described in `issue 2086`\_. This is now fixed. - Fix: we now defend against aggressive mocking of `open()` that could cause errors inside coverage.py. An example of a failure is in `issue 2083`\_. - Fix: in unusual cases where a test suite intentionally exhausts the system's file descriptors to test handling errors in `open()`, coverage.py would fail when trying to open source files, as described in `issue 2091`\_. This is now fixed. - A small tweak to the HTML report: file paths now use thin spaces around slashes to make them easier to read. .. \_issue 2081: [#​2081](https://redirect.github.com/coveragepy/coveragepy/issues/2081) .. \_issue 2083: [#​2083](https://redirect.github.com/coveragepy/coveragepy/issues/2083) .. \_pull 2085: [#​2085](https://redirect.github.com/coveragepy/coveragepy/pull/2085) .. \_issue 2086: [#​2086](https://redirect.github.com/coveragepy/coveragepy/issues/2086) .. \_pull 2090: [#​2090](https://redirect.github.com/coveragepy/coveragepy/pull/2090) .. \_issue 2091: [#​2091](https://redirect.github.com/coveragepy/coveragepy/issues/2091) .. \_changes\_7-11-3:
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index abd982e6..1f5f9e67 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.11,<7.12", + "coverage>=7.12,<7.13", "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.1", From 57cd6a41e23f8c57305b7bfd1e39bd573a42115f Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 27 Nov 2025 15:28:49 +0100 Subject: [PATCH 137/213] feat: add update rrset records action to zone client (#597) - https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset --- hcloud/zones/client.py | 55 +++++++++++++++++++++++++++++++++ tests/unit/zones/test_client.py | 51 ++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index 832d76f7..b3346042 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -359,6 +359,21 @@ def add_rrset_records( """ return self._client.add_rrset_records(rrset=rrset, records=records, ttl=ttl) + def update_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to update in the RRSet. + """ + return self._client.update_rrset_records(rrset=rrset, records=records) + def remove_rrset_records( self, rrset: ZoneRRSet | BoundZoneRRSet, @@ -479,6 +494,19 @@ def add_rrset_records( """ return self._client.add_rrset_records(self, records=records, ttl=ttl) + def update_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + + :param records: Records to update in the RRSet. + """ + return self._client.update_rrset_records(self, records=records) + def remove_rrset_records( self, records: list[ZoneRecord], @@ -1172,6 +1200,33 @@ def add_rrset_records( ) return BoundAction(self._parent.actions, response["action"]) + def update_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to update in the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/update_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + def remove_rrset_records( self, rrset: ZoneRRSet | BoundZoneRRSet, diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py index 2e966032..a2f7d7c7 100644 --- a/tests/unit/zones/test_client.py +++ b/tests/unit/zones/test_client.py @@ -752,6 +752,55 @@ def test_add_rrset_records( assert_bound_action1(action, resource_client._parent.actions) + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_update_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.update_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("198.51.100.2", ""), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/update_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "198.51.100.2", "comment": ""}, + {"value": "127.0.0.1"}, + ], + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + @pytest.mark.parametrize( "zone", [ @@ -866,6 +915,7 @@ class TestBoundZone(BoundModelTestCase): (BoundZone.change_rrset_protection, {"sub_resource": True}), (BoundZone.change_rrset_ttl, {"sub_resource": True}), (BoundZone.add_rrset_records, {"sub_resource": True}), + (BoundZone.update_rrset_records, {"sub_resource": True}), (BoundZone.remove_rrset_records, {"sub_resource": True}), (BoundZone.set_rrset_records, {"sub_resource": True}), ] @@ -927,6 +977,7 @@ class TestBoundZoneRRSet(BoundModelTestCase): BoundZoneRRSet.change_rrset_protection, BoundZoneRRSet.change_rrset_ttl, BoundZoneRRSet.add_rrset_records, + BoundZoneRRSet.update_rrset_records, BoundZoneRRSet.remove_rrset_records, BoundZoneRRSet.set_rrset_records, ] From d1f8d75020128d0de01ad5c5911c9b390ec8147b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:11:00 +0100 Subject: [PATCH 138/213] chore(deps): update dependency coverage to >=7.13,<7.14 (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [coverage](https://redirect.github.com/coveragepy/coveragepy) | `>=7.12,<7.13` -> `>=7.13,<7.14` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/coverage/7.13.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/coverage/7.12.0/7.13.0?slim=true) | --- ### Release Notes
coveragepy/coveragepy (coverage) ### [`v7.13.0`](https://redirect.github.com/coveragepy/coveragepy/blob/HEAD/CHANGES.rst#Version-7130--2025-12-08) [Compare Source](https://redirect.github.com/coveragepy/coveragepy/compare/7.12.0...7.13.0) - Feature: coverage.py now supports :file:`.coveragerc.toml` configuration files. These files use TOML syntax and take priority over :file:`pyproject.toml` but lower priority than :file:`.coveragerc` files. Closes `issue 1643`\_ thanks to `Olena Yefymenko `\_. - Fix: we now include a permanent .pth file which is installed with the code, fixing `issue 2084`*. In 7.12.1b1 this was done incorrectly: it didn't work when using the source wheel (`py3-none-any`). This is now fixed. Thanks, `Henry Schreiner `*. - Deprecated: when coverage.py is installed, it creates three command entry points: `coverage`, `coverage3`, and `coverage-3.10` (if installed for Python 3.10). The second and third of these are not needed and will eventually be removed. They still work for now, but print a message about their deprecation. .. \_issue 1643: [#​1643](https://redirect.github.com/coveragepy/coveragepy/issues/1643) .. \_pull 1952: [#​1952](https://redirect.github.com/coveragepy/coveragepy/pull/1952) .. \_pull 2100: [#​2100](https://redirect.github.com/coveragepy/coveragepy/pull/2100) .. \_changes\_7-12-1b1:
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 1f5f9e67..6458ac36 100644 --- a/setup.py +++ b/setup.py @@ -46,7 +46,7 @@ "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.12,<7.13", + "coverage>=7.13,<7.14", "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.1", From 6094b87943179616bc018c900e3ceaaed2e969d8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:11:10 +0100 Subject: [PATCH 139/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v25.12.0 (#602) 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 | `25.11.0` -> `25.12.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) ### [`v25.12.0`](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.11.0...25.12.0) [Compare Source](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.11.0...25.12.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 6e21ba29..0796b3cb 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: 25.11.0 + rev: 25.12.0 hooks: - id: black From 79ffad3df2ad12feb07640533d11031e6e3ecd33 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:11:26 +0100 Subject: [PATCH 140/213] chore(deps): update dependency mypy to >=1.19,<1.20 (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [mypy](https://redirect.github.com/python/mypy) ([changelog](https://mypy.readthedocs.io/en/latest/changelog.html)) | `>=1.18,<1.19` -> `>=1.19,<1.20` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/mypy/1.19.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/mypy/1.18.2/1.19.0?slim=true) | --- ### Release Notes
python/mypy (mypy) ### [`v1.19.0`](https://redirect.github.com/python/mypy/compare/v1.18.2...v1.19.0) [Compare Source](https://redirect.github.com/python/mypy/compare/v1.18.2...v1.19.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6458ac36..328388d0 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ "pylint>=4,<4.1", "pytest>=9,<9.1", "pytest-cov>=7,<7.1", - "mypy>=1.18,<1.19", + "mypy>=1.19,<1.20", "types-python-dateutil", "types-requests", ], From 48551dac11f9180937a9cf2f9fbd6feacfee61af Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 10 Dec 2025 11:55:23 +0100 Subject: [PATCH 141/213] feat: add support for Storage Boxes (#524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We collect all changes for the Storage Box support in this PR. It will only be merged when everything is implemented through smaller pull requests targeting the `storage-boxes` branch. Implements https://docs.hetzner.cloud/reference/hetzner#storage-boxes Closes #526 --------- Co-authored-by: Julian TΓΆlle --- docs/api.clients.storage_box_types.rst | 14 + docs/api.clients.storage_boxes.rst | 75 + hcloud/_client.py | 26 + hcloud/storage_box_types/__init__.py | 15 + hcloud/storage_box_types/client.py | 124 ++ hcloud/storage_box_types/domain.py | 49 + hcloud/storage_boxes/__init__.py | 55 + hcloud/storage_boxes/client.py | 1790 +++++++++++++++++++ hcloud/storage_boxes/domain.py | 470 +++++ tests/unit/actions/test_client.py | 2 + tests/unit/conftest.py | 11 +- tests/unit/storage_box_types/__init__.py | 0 tests/unit/storage_box_types/conftest.py | 50 + tests/unit/storage_box_types/test_client.py | 165 ++ tests/unit/storage_box_types/test_domain.py | 15 + tests/unit/storage_boxes/__init__.py | 0 tests/unit/storage_boxes/conftest.py | 169 ++ tests/unit/storage_boxes/test_client.py | 1186 ++++++++++++ tests/unit/storage_boxes/test_domain.py | 15 + 19 files changed, 4229 insertions(+), 2 deletions(-) create mode 100644 docs/api.clients.storage_box_types.rst create mode 100644 docs/api.clients.storage_boxes.rst create mode 100644 hcloud/storage_box_types/__init__.py create mode 100644 hcloud/storage_box_types/client.py create mode 100644 hcloud/storage_box_types/domain.py create mode 100644 hcloud/storage_boxes/__init__.py create mode 100644 hcloud/storage_boxes/client.py create mode 100644 hcloud/storage_boxes/domain.py create mode 100644 tests/unit/storage_box_types/__init__.py create mode 100644 tests/unit/storage_box_types/conftest.py create mode 100644 tests/unit/storage_box_types/test_client.py create mode 100644 tests/unit/storage_box_types/test_domain.py create mode 100644 tests/unit/storage_boxes/__init__.py create mode 100644 tests/unit/storage_boxes/conftest.py create mode 100644 tests/unit/storage_boxes/test_client.py create mode 100644 tests/unit/storage_boxes/test_domain.py diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst new file mode 100644 index 00000000..9d58506e --- /dev/null +++ b/docs/api.clients.storage_box_types.rst @@ -0,0 +1,14 @@ +StorageBoxTypesClient +===================== + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesClient + :members: + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesPageResult + :members: + +.. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType + :members: + +.. autoclass:: hcloud.storage_box_types.domain.StorageBoxType + :members: diff --git a/docs/api.clients.storage_boxes.rst b/docs/api.clients.storage_boxes.rst new file mode 100644 index 00000000..604d9167 --- /dev/null +++ b/docs/api.clients.storage_boxes.rst @@ -0,0 +1,75 @@ +StorageBoxesClient +===================== + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesClient + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotsPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSubaccountsPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSnapshot + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSubaccount + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxAccessSettings + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotPlan + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStats + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStatus + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshot + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotStats + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccount + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccountAccessSettings + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSubaccountResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxFoldersResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSubaccountResponse + :members: diff --git a/hcloud/_client.py b/hcloud/_client.py index 4c499585..2bf147ec 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -25,6 +25,8 @@ from .server_types import ServerTypesClient from .servers import ServersClient from .ssh_keys import SSHKeysClient +from .storage_box_types import StorageBoxTypesClient +from .storage_boxes import StorageBoxesClient from .volumes import VolumesClient from .zones import ZonesClient @@ -141,11 +143,14 @@ def __init__( poll_interval: int | float | BackoffFunction = 1.0, poll_max_retries: int = 120, timeout: float | tuple[float, float] | None = None, + *, + api_endpoint_hetzner: str = "https://api.hetzner.com/v1", ): """Create a new Client instance :param token: Hetzner Cloud API token :param api_endpoint: Hetzner Cloud API endpoint + :param api_endpoint_hetzner: Hetzner API endpoint. :param application_name: Your application name :param application_version: Your application _version :param poll_interval: @@ -164,6 +169,15 @@ def __init__( poll_max_retries=poll_max_retries, timeout=timeout, ) + self._client_hetzner = ClientBase( + token=token, + endpoint=api_endpoint_hetzner, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) self.datacenters = DatacentersClient(self) """DatacentersClient Instance @@ -261,6 +275,18 @@ def __init__( :type: :class:`ZonesClient ` """ + self.storage_box_types = StorageBoxTypesClient(self) + """StorageBoxTypesClient Instance + + :type: :class:`StorageBoxTypesClient ` + """ + + self.storage_boxes = StorageBoxesClient(self) + """StorageBoxesClient Instance + + :type: :class:`StorageBoxesClient ` + """ + def request( # type: ignore[no-untyped-def] self, method: str, diff --git a/hcloud/storage_box_types/__init__.py b/hcloud/storage_box_types/__init__.py new file mode 100644 index 00000000..28d832b0 --- /dev/null +++ b/hcloud/storage_box_types/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBoxType, + StorageBoxTypesClient, + StorageBoxTypesPageResult, +) +from .domain import StorageBoxType + +__all__ = [ + "BoundStorageBoxType", + "StorageBoxType", + "StorageBoxTypesClient", + "StorageBoxTypesPageResult", +] diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py new file mode 100644 index 00000000..05cb03e8 --- /dev/null +++ b/hcloud/storage_box_types/client.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import StorageBoxType + +if TYPE_CHECKING: + from .._client import Client + + +class BoundStorageBoxType(BoundModelBase, StorageBoxType): + _client: StorageBoxTypesClient + + model = StorageBoxType + + +class StorageBoxTypesPageResult(NamedTuple): + storage_box_types: list[BoundStorageBoxType] + meta: Meta + + +class StorageBoxTypesClient(ResourceClientBase): + """ + A client for the Storage Box Types API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + + _base_url = "/storage_box_types" + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + + def get_by_id(self, id: int) -> BoundStorageBoxType: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-get-a-storage-box-type + + :param id: ID of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id}", + ) + return BoundStorageBoxType(self, response["storage_box_type"]) + + def get_by_name(self, name: str) -> BoundStorageBoxType | None: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._get_first_by(self.get_list, name=name) + + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxTypesPageResult: + """ + Returns a list of Storage Box Types for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return StorageBoxTypesPageResult( + storage_box_types=[ + BoundStorageBoxType(self, o) for o in response["storage_box_types"] + ], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + name: str | None = None, + ) -> list[BoundStorageBoxType]: + """ + Returns all Storage Box Types. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._iter_pages( + self.get_list, + name=name, + ) diff --git a/hcloud/storage_box_types/domain.py b/hcloud/storage_box_types/domain.py new file mode 100644 index 00000000..b807ce27 --- /dev/null +++ b/hcloud/storage_box_types/domain.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + + +class StorageBoxType(BaseDomain, DomainIdentityMixin): + """ + Storage Box Type Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "snapshot_limit", + "automatic_snapshot_limit", + "subaccounts_limit", + "size", + "deprecation", + "prices", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + snapshot_limit: int | None = None, + automatic_snapshot_limit: int | None = None, + subaccounts_limit: int | None = None, + size: int | None = None, + prices: list[dict] | None = None, + deprecation: dict | None = None, + ): + self.id = id + self.name = name + self.description = description + self.snapshot_limit = snapshot_limit + self.automatic_snapshot_limit = automatic_snapshot_limit + self.subaccounts_limit = subaccounts_limit + self.size = size + self.prices = prices + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py new file mode 100644 index 00000000..4eb22722 --- /dev/null +++ b/hcloud/storage_boxes/__init__.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + StorageBoxesClient, + StorageBoxesPageResult, + StorageBoxSnapshotsPageResult, + StorageBoxSubaccountsPageResult, +) +from .domain import ( + CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, + DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, + StorageBoxStats, + StorageBoxStatus, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +__all__ = [ + "BoundStorageBox", + "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", + "CreateStorageBoxResponse", + "CreateStorageBoxSnapshotResponse", + "CreateStorageBoxSubaccountResponse", + "DeleteStorageBoxResponse", + "DeleteStorageBoxSnapshotResponse", + "DeleteStorageBoxSubaccountResponse", + "StorageBox", + "StorageBoxAccessSettings", + "StorageBoxesClient", + "StorageBoxesPageResult", + "StorageBoxFoldersResponse", + "StorageBoxSnapshot", + "StorageBoxSnapshotPlan", + "StorageBoxSnapshotsPageResult", + "StorageBoxSnapshotStats", + "StorageBoxStats", + "StorageBoxStatus", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "StorageBoxSubaccountsPageResult", +] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py new file mode 100644 index 00000000..0cb3c2db --- /dev/null +++ b/hcloud/storage_boxes/client.py @@ -0,0 +1,1790 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation, Location +from ..ssh_keys import BoundSSHKey, SSHKey +from ..storage_box_types import BoundStorageBoxType, StorageBoxType +from .domain import ( + CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, + DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, + StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +if TYPE_CHECKING: + from .._client import Client + + +class BoundStorageBox(BoundModelBase, StorageBox): + _client: StorageBoxesClient + + model = StorageBox + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box_type") + if raw is not None: + data["storage_box_type"] = BoundStorageBoxType( + client._parent.storage_box_types, raw + ) + + raw = data.get("location") + if raw is not None: + data["location"] = BoundLocation(client._parent.locations, raw) + + raw = data.get("snapshot_plan") + if raw is not None: + data["snapshot_plan"] = StorageBoxSnapshotPlan.from_dict(raw) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxAccessSettings.from_dict(raw) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxStats.from_dict(raw) + + super().__init__(client, data, complete) + + def get_actions_list( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Storage Box for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + + def update( + self, + *, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBox: + """ + Updates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box + + :param name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.update( + self, + name=name, + labels=labels, + ) + + def delete(self) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.delete(self) + + def get_folders( + self, + *, + path: str | None = None, + ) -> StorageBoxFoldersResponse: + """ + Lists the (sub)folders contained in a Storage Box. + + Files are not part of the response. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box + + :param path: Relative path to list the folders from. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_folders( + self, + path=path, + ) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param delete: Prevents the Storage Box from being deleted. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.change_protection( + self, + delete=delete, + ) + + def change_type( + self, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box_type: Storage Box Type to change to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.change_type( + self, + storage_box_type=storage_box_type, + ) + + def reset_password( + self, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param password: New password. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.reset_password( + self, + password=password, + ) + + def update_access_settings( + self, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param access_settings: New access settings for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.update_access_settings( + self, + access_settings=access_settings, + ) + + def rollback_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param snapshot: Snapshot to rollback to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.rollback_snapshot( + self, + snapshot=snapshot, + ) + + def disable_snapshot_plan( + self, + ) -> BoundAction: + """ + Disable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.disable_snapshot_plan(self) + + def enable_snapshot_plan( + self, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param snapshot_plan: Snapshot Plan to enable. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.enable_snapshot_plan( + self, + snapshot_plan=snapshot_plan, + ) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param id: ID of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_snapshot_by_id(self, id=id) + + def get_snapshot_by_name( + self, + name: str, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Name of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_snapshot_by_name(self, name=name) + + def get_snapshot_list( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_snapshot_list( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def get_snapshot_all( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_snapshot_all( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def create_snapshot( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.create_snapshot( + self, + description=description, + labels=labels, + ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param id: ID of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_subaccount_by_id(self, id=id) + + def get_subaccount_by_username( + self, + username: str, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: User name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_subaccount_by_username(self, username=username) + + def get_subaccount_list( + self, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_subaccount_list( + self, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def get_subaccount_all( + self, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_subaccount_all( + self, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def create_subaccount( + self, + *, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.create_subaccount( + self, + home_directory=home_directory, + password=password, + access_settings=access_settings, + description=description, + labels=labels, + ) + + +class BoundStorageBoxSnapshot(BoundModelBase, StorageBoxSnapshot): + _client: StorageBoxesClient + + model = StorageBoxSnapshot + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxSnapshotStats.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSnapshot: + return self._client.get_snapshot_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + def update( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.update_snapshot( + self, + description=description, + labels=labels, + ) + + def delete( + self, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.delete_snapshot(self) + + +class BoundStorageBoxSubaccount(BoundModelBase, StorageBoxSubaccount): + _client: StorageBoxesClient + + model = StorageBoxSubaccount + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxSubaccountAccessSettings.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSubaccount: + return self._client.get_subaccount_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + def update( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.update_subaccount( + self, + description=description, + labels=labels, + ) + + def delete( + self, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.delete_subaccount(self) + + def change_home_directory( + self, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param home_directory: Home directory for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.change_subaccount_home_directory( + self, home_directory=home_directory + ) + + def reset_password( + self, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param password: Password for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.reset_subaccount_password(self, password=password) + + def update_access_settings( + self, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param access_settings: Access settings for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.update_subaccount_access_settings( + self, + access_settings=access_settings, + ) + + +class StorageBoxesPageResult(NamedTuple): + storage_boxes: list[BoundStorageBox] + meta: Meta + + +class StorageBoxSnapshotsPageResult(NamedTuple): + snapshots: list[BoundStorageBoxSnapshot] + meta: Meta + + +class StorageBoxSubaccountsPageResult(NamedTuple): + subaccounts: list[BoundStorageBoxSubaccount] + meta: Meta + + +class StorageBoxesClient(ResourceClientBase): + """ + A client for the Storage Boxes API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + + _base_url = "/storage_boxes" + + actions: ResourceActionsClient + """Storage Boxes scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + self.actions = ResourceActionsClient(self, self._base_url) + + def get_by_id(self, id: int) -> BoundStorageBox: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box + + :param id: ID of the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id}", + ) + return BoundStorageBox(self, response["storage_box"]) + + def get_by_name(self, name: str) -> BoundStorageBox | None: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._get_first_by(self.get_list, name=name) + + def get_list( + self, + *, + name: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxesPageResult: + """ + Returns a paginated list of Storage Boxes for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if label_selector is not None: + params["label_selector"] = label_selector + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return StorageBoxesPageResult( + storage_boxes=[BoundStorageBox(self, o) for o in response["storage_boxes"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + *, + name: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBox]: + """ + Returns all Storage Boxes. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._iter_pages( + self.get_list, + name=name, + label_selector=label_selector, + sort=sort, + ) + + def create( + self, + *, + name: str, + password: str, + location: BoundLocation | Location, + storage_box_type: BoundStorageBoxType | StorageBoxType, + ssh_keys: list[str | SSHKey | BoundSSHKey] | None = None, + access_settings: StorageBoxAccessSettings | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxResponse: + """ + Creates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-create-a-storage-box + + :param name: Name of the Storage Box. + :param password: Password of the Storage Box. + :param location: Location of the Storage Box. + :param storage_box_type: Type of the Storage Box. + :param ssh_keys: SSH public keys of the Storage Box. + :param access_settings: Access settings of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = { + "name": name, + "password": password, + "location": location.id_or_name, + "storage_box_type": storage_box_type.id_or_name, + } + if ssh_keys is not None: + data["ssh_keys"] = [ + o.public_key if isinstance(o, (SSHKey, BoundSSHKey)) else o + for o in ssh_keys + ] + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}", + json=data, + ) + + return CreateStorageBoxResponse( + storage_box=BoundStorageBox(self, response["storage_box"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update( + self, + storage_box: BoundStorageBox | StorageBox, + *, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBox: + """ + Updates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box + + :param storage_box: Storage Box to update. + :param name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{storage_box.id}", + json=data, + ) + + return BoundStorageBox(self, response["storage_box"]) + + def delete( + self, + storage_box: BoundStorageBox | StorageBox, + ) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + + :param storage_box: Storage Box to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{storage_box.id}", + ) + + return DeleteStorageBoxResponse( + action=BoundAction(self._parent.actions, response["action"]) + ) + + def get_folders( + self, + storage_box: BoundStorageBox | StorageBox, + *, + path: str | None = None, + ) -> StorageBoxFoldersResponse: + """ + Lists the (sub)folders contained in a Storage Box. + + Files are not part of the response. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box + + :param storage_box: Storage Box to list the folders from. + :param path: Relative path to list the folders from. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if path is not None: + params["path"] = path + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/folders", + params=params, + ) + + return StorageBoxFoldersResponse(folders=response["folders"]) + + def get_actions_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Storage Box for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"/storage_boxes/{storage_box.id}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + def get_actions( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._iter_pages( + self.get_actions_list, + storage_box, + status=status, + sort=sort, + ) + + def change_protection( + self, + storage_box: StorageBox | BoundStorageBox, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param storage_box: Storage Box to update. + :param delete: Prevents the Storage Box from being deleted. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_type( + self, + storage_box: StorageBox | BoundStorageBox, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box: Storage Box to update. + :param storage_box_type: Storage Box Type to change to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = { + "storage_box_type": storage_box_type.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_type", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_password( + self, + storage_box: StorageBox | BoundStorageBox, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param storage_box: Storage Box to update. + :param password: New password. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/reset_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_access_settings( + self, + storage_box: StorageBox | BoundStorageBox, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param storage_box: Storage Box to update. + :param access_settings: New access settings for the Storage Box. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def rollback_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param storage_box: Storage Box to update. + :param snapshot: Snapshot to rollback to. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = { + "snapshot": snapshot.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/rollback_snapshot", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def disable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + ) -> BoundAction: + """ + Disable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + + :param storage_box: Storage Box to update. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/disable_snapshot_plan", + ) + return BoundAction(self._parent.actions, response["action"]) + + def enable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param storage_box: Storage Box to update. + :param snapshot_plan: Snapshot Plan to enable. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = snapshot_plan.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/enable_snapshot_plan", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param storage_box: Storage Box to get the Snapshot from. + :param id: ID of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots/{id}", + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def get_snapshot_by_name( + self, + storage_box: StorageBox | BoundStorageBox, + name: str, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshot from. + :param name: Name of the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._get_first_by(self.get_snapshot_list, storage_box, name=name) + + def get_snapshot_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if is_automatic is not None: + params["is_automatic"] = is_automatic + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots", + params=params, + ) + return StorageBoxSnapshotsPageResult( + snapshots=[ + BoundStorageBoxSnapshot(self, item) for item in response["snapshots"] + ], + meta=Meta.parse_meta(response), + ) + + def get_snapshot_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_snapshot_list( + storage_box, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param storage_box: Storage Box to create a Snapshot from. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/snapshots", + json=data, + ) + return CreateStorageBoxSnapshotResponse( + snapshot=BoundStorageBoxSnapshot( + self, + response["snapshot"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param snapshot: Storage Box Snapshot to update. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + json=data, + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def delete_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + + :param snapshot: Storage Box Snapshot to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + ) + return DeleteStorageBoxSnapshotResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param storage_box: Storage Box to get the Subaccount from. + :param id: ID of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts/{id}", + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def get_subaccount_by_username( + self, + storage_box: StorageBox | BoundStorageBox, + username: str, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: User name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + username=username, + ) + + def get_subaccount_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + params: dict[str, Any] = {} + if username is not None: + params["username"] = username + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + params=params, + ) + return StorageBoxSubaccountsPageResult( + subaccounts=[ + BoundStorageBoxSubaccount(self, item) + for item in response["subaccounts"] + ], + meta=Meta.parse_meta(response), + ) + + def get_subaccount_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_subaccount_list( + storage_box, + username=username, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_subaccount( + self, + storage_box: StorageBox | BoundStorageBox, + *, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + data: dict[str, Any] = { + "home_directory": home_directory, + "password": password, + } + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + json=data, + ) + return CreateStorageBoxSubaccountResponse( + subaccount=BoundStorageBoxSubaccount( + self, + response["subaccount"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param subaccount: Storage Box Subaccount to update. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + json=data, + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def delete_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + :param subaccount: Storage Box Subaccount to delete. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + ) + return DeleteStorageBoxSubaccountResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def change_subaccount_home_directory( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param subaccount: Storage Box Subaccount to update. + :param home_directory: Home directory for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "home_directory": home_directory, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/change_home_directory", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_subaccount_password( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param subaccount: Storage Box Subaccount to update. + :param password: Password for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/reset_subaccount_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_subaccount_access_settings( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param subaccount: Storage Box Subaccount to update. + :param access_settings: Access settings for the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py new file mode 100644 index 00000000..7d94fcb2 --- /dev/null +++ b/hcloud/storage_boxes/domain.py @@ -0,0 +1,470 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from dateutil.parser import isoparse + +from ..actions import BoundAction +from ..core import BaseDomain, DomainIdentityMixin +from ..locations import BoundLocation, Location +from ..storage_box_types import BoundStorageBoxType, StorageBoxType + +if TYPE_CHECKING: + from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + ) + +StorageBoxStatus = Literal[ + "active", + "initializing", + "locked", +] + + +class StorageBox(BaseDomain, DomainIdentityMixin): + """ + Storage Box Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + STATUS_ACTIVE = "active" + STATUS_INITIALIZING = "initializing" + STATUS_LOCKED = "locked" + + __api_properties__ = ( + "id", + "name", + "storage_box_type", + "location", + "system", + "server", + "username", + "labels", + "protection", + "snapshot_plan", + "access_settings", + "stats", + "status", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + storage_box_type: BoundStorageBoxType | StorageBoxType | None = None, + location: BoundLocation | Location | None = None, + system: str | None = None, + server: str | None = None, + username: str | None = None, + labels: dict[str, str] | None = None, + protection: dict[str, bool] | None = None, + snapshot_plan: StorageBoxSnapshotPlan | None = None, + access_settings: StorageBoxAccessSettings | None = None, + stats: StorageBoxStats | None = None, + status: StorageBoxStatus | None = None, + created: str | None = None, + ): + self.id = id + self.name = name + self.storage_box_type = storage_box_type + self.location = location + self.system = system + self.server = server + self.username = username + self.labels = labels + self.protection = protection + self.snapshot_plan = snapshot_plan + self.access_settings = access_settings + self.stats = stats + self.status = status + self.created = isoparse(created) if created else None + + +class StorageBoxAccessSettings(BaseDomain): + """ + Storage Box Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "zfs_enabled", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + zfs_enabled: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.zfs_enabled = zfs_enabled + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.zfs_enabled is not None: + payload["zfs_enabled"] = self.zfs_enabled + return payload + + +class StorageBoxStats(BaseDomain): + """ + Storage Box Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_data", + "size_snapshots", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int | None = None, + size_data: int | None = None, + size_snapshots: int | None = None, + ): + self.size = size + self.size_data = size_data + self.size_snapshots = size_snapshots + + +class StorageBoxSnapshotPlan(BaseDomain): + """ + Storage Box Snapshot Plan Domain. + """ + + __api_properties__ = ( + "max_snapshots", + "hour", + "minute", + "day_of_week", + "day_of_month", + ) + __slots__ = __api_properties__ + + def __init__( + self, + max_snapshots: int, + hour: int, + minute: int, + day_of_week: int | None = None, + day_of_month: int | None = None, + ): + self.max_snapshots = max_snapshots + self.hour = hour + self.minute = minute + self.day_of_week = day_of_week + self.day_of_month = day_of_month + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "max_snapshots": self.max_snapshots, + "hour": self.hour, + "minute": self.minute, + "day_of_week": self.day_of_week, # API default is null + "day_of_month": self.day_of_month, # API default is null + } + + return payload + + +class CreateStorageBoxResponse(BaseDomain): + """ + Create Storage Box Response Domain. + """ + + __api_properties__ = ( + "storage_box", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + storage_box: BoundStorageBox, + action: BoundAction, + ): + self.storage_box = storage_box + self.action = action + + +class DeleteStorageBoxResponse(BaseDomain): + """ + Delete Storage Box Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class StorageBoxFoldersResponse(BaseDomain): + """ + Storage Box Folders Response Domain. + """ + + __api_properties__ = ("folders",) + __slots__ = __api_properties__ + + def __init__( + self, + folders: list[str], + ): + self.folders = folders + + +# Snapshots +############################################################################### + + +class StorageBoxSnapshot(BaseDomain, DomainIdentityMixin): + """ + Storage Box Snapshot Domain. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "is_automatic", + "labels", + "storage_box", + "created", + "stats", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + is_automatic: bool | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + stats: StorageBoxSnapshotStats | None = None, + ): + self.id = id + self.name = name + self.description = description + self.is_automatic = is_automatic + self.labels = labels + self.storage_box = storage_box + self.created = isoparse(created) if created else None + self.stats = stats + + +class StorageBoxSnapshotStats(BaseDomain): + """ + Storage Box Snapshot Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_filesystem", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int, + size_filesystem: int, + ): + self.size = size + self.size_filesystem = size_filesystem + + +class CreateStorageBoxSnapshotResponse(BaseDomain): + """ + Create Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ( + "snapshot", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + snapshot: BoundStorageBoxSnapshot, + action: BoundAction, + ): + self.snapshot = snapshot + self.action = action + + +class DeleteStorageBoxSnapshotResponse(BaseDomain): + """ + Delete Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +# Subaccounts +############################################################################### + + +class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): + """ + Storage Box Subaccount Domain. + """ + + __api_properties__ = ( + "id", + "username", + "description", + "server", + "home_directory", + "access_settings", + "labels", + "storage_box", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + username: str | None = None, + description: str | None = None, + server: str | None = None, + home_directory: str | None = None, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + ): + self.id = id + self.username = username + self.description = description + self.server = server + self.home_directory = home_directory + self.access_settings = access_settings + self.labels = labels + self.storage_box = storage_box + self.created = isoparse(created) if created else None + + +class StorageBoxSubaccountAccessSettings(BaseDomain): + """ + Storage Box Subaccount Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "readonly", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + readonly: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.readonly = readonly + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.readonly is not None: + payload["readonly"] = self.readonly + return payload + + +class CreateStorageBoxSubaccountResponse(BaseDomain): + """ + Create Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ( + "subaccount", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + subaccount: BoundStorageBoxSubaccount, + action: BoundAction, + ): + self.subaccount = subaccount + self.action = action + + +class DeleteStorageBoxSubaccountResponse(BaseDomain): + """ + Delete Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 6c09f7dd..0b708cfb 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -22,6 +22,7 @@ from hcloud.networks import BoundNetwork, NetworksClient from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient from hcloud.servers import BoundServer, ServersClient +from hcloud.storage_boxes import BoundStorageBox, StorageBoxesClient from hcloud.volumes import BoundVolume, VolumesClient from hcloud.zones import BoundZone, ZonesClient @@ -38,6 +39,7 @@ "servers": (ServersClient, BoundServer), "volumes": (VolumesClient, BoundVolume), "zones": (ZonesClient, BoundZone), + "storage_boxes": (StorageBoxesClient, BoundStorageBox), } diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 17c37704..0d2a8ecb 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -33,6 +33,7 @@ def client(request_mock) -> Client: poll_max_retries=3, ) c._client.request = request_mock + c._client_hetzner.request = request_mock return c @@ -169,6 +170,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc): class BoundModelTestOptions(TypedDict): sub_resource: bool + client_method: str class BoundModelTestCase: @@ -214,14 +216,19 @@ def test_method( if isinstance(bound_model_method, tuple): bound_model_method, options = bound_model_method + resource_client_method_name = options.get( + "client_method", + bound_model_method.__name__, + ) + # Check if the resource client has a method named after the bound model method. - assert hasattr(resource_client, bound_model_method.__name__) + assert hasattr(resource_client, resource_client_method_name) # Mock the resource client method. resource_client_method_mock = mock.MagicMock() setattr( resource_client, - bound_model_method.__name__, + resource_client_method_name, resource_client_method_mock, ) diff --git a/tests/unit/storage_box_types/__init__.py b/tests/unit/storage_box_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_box_types/conftest.py b/tests/unit/storage_box_types/conftest.py new file mode 100644 index 00000000..e2717142 --- /dev/null +++ b/tests/unit/storage_box_types/conftest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box_type1(): + return { + "id": 42, + "name": "bx11", + "description": "BX11", + "snapshot_limit": 10, + "automatic_snapshot_limit": 10, + "subaccounts_limit": 100, + "size": 1099511627776, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ], + "deprecation": { + "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00+00:00", + }, + } + + +@pytest.fixture() +def storage_box_type2(): + return { + "id": 43, + "name": "bx21", + "description": "BX21", + "snapshot_limit": 20, + "automatic_snapshot_limit": 20, + "subaccounts_limit": 100, + "size": 5497558138880, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"net": "1.0000", "gross": "1.1900"}, + "price_monthly": {"net": "1.0000", "gross": "1.1900"}, + "setup_fee": {"net": "1.0000", "gross": "1.1900"}, + } + ], + "deprecation": None, + } diff --git a/tests/unit/storage_box_types/test_client.py b/tests/unit/storage_box_types/test_client.py new file mode 100644 index 00000000..ee63a0f1 --- /dev/null +++ b/tests/unit/storage_box_types/test_client.py @@ -0,0 +1,165 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.storage_box_types import ( + BoundStorageBoxType, + StorageBoxTypesClient, +) + + +def assert_bound_model( + o: BoundStorageBoxType, + client: StorageBoxTypesClient, +): + assert isinstance(o, BoundStorageBoxType) + assert o._client is client + assert o.id == 42 + assert o.name == "bx11" + + +class TestClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxTypesClient: + return client.storage_box_types + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_type": storage_box_type1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types/42", + ) + + assert_bound_model(result, resource_client) + assert result.description == "BX11" + assert result.snapshot_limit == 10 + assert result.automatic_snapshot_limit == 10 + assert result.subaccounts_limit == 100 + assert result.size == 1099511627776 + assert result.prices == [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ] + assert result.deprecation.announced == isoparse("2023-06-01T00:00:00+00:00") + assert result.deprecation.unavailable_after == isoparse( + "2023-09-01T00:00:00+00:00" + ) + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11", "page": 1, "per_page": 10}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + url="/storage_box_types", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_box_types) == 2 + + result1 = result.storage_box_types[0] + result2 = result.storage_box_types[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + url="/storage_box_types", + method="GET", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_types": [storage_box_type1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types", + params=params, + ) + + assert_bound_model(result, resource_client) diff --git a/tests/unit/storage_box_types/test_domain.py b/tests/unit/storage_box_types/test_domain.py new file mode 100644 index 00000000..829c906a --- /dev/null +++ b/tests/unit/storage_box_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_box_types import StorageBoxType + + +@pytest.mark.parametrize( + "value", + [ + (StorageBoxType(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/storage_boxes/__init__.py b/tests/unit/storage_boxes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py new file mode 100644 index 00000000..190c53d0 --- /dev/null +++ b/tests/unit/storage_boxes/conftest.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box1(): + return { + "id": 42, + "name": "storage-box1", + "created": "2025-01-30T23:55:00+00:00", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 42, + "name": "bx11", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "reachable_externally": False, + "samba_enabled": False, + "ssh_enabled": False, + "webdav_enabled": False, + "zfs_enabled": False, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": { + "key": "value", + }, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def storage_box2(): + return { + "id": 43, + "name": "storage-box2", + "created": "2022-09-30T10:30:09.000Z", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 1334, + "name": "bx21", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "webdav_enabled": False, + "zfs_enabled": False, + "samba_enabled": False, + "ssh_enabled": True, + "reachable_externally": True, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": {}, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def storage_box_snapshot1(): + return { + "id": 34, + "name": "storage-box-snapshot1", + "description": "", + "is_automatic": False, + "stats": { + "size": 394957594, + "size_filesystem": 3949572745, + }, + "labels": { + "key": "value", + }, + "created": "2025-11-10T19:16:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_snapshot2(): + return { + "id": 35, + "name": "storage-box-snapshot2", + "description": "", + "is_automatic": True, + "stats": { + "size": 0, + "size_filesystem": 0, + }, + "labels": {}, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount1(): + return { + "id": 45, + "username": "u42-sub1", + "server": "u42-sub1.your-storagebox.de", + "home_directory": "tmp/", + "description": "Required by foo", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": { + "key": "value", + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount2(): + return { + "id": 46, + "username": "u42-sub2", + "server": "u42-sub2.your-storagebox.de", + "home_directory": "backup/", + "description": "", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": {}, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py new file mode 100644 index 00000000..0daff945 --- /dev/null +++ b/tests/unit/storage_boxes/test_client.py @@ -0,0 +1,1186 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.locations import Location +from hcloud.storage_box_types import StorageBoxType +from hcloud.storage_boxes import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + StorageBox, + StorageBoxAccessSettings, + StorageBoxesClient, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_storage_box( + o: BoundStorageBox, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBox) + assert o._client is resource_client + assert o.id == 42 + assert o.name == "storage-box1" + + +def assert_bound_storage_box_snapshot( + o: BoundStorageBoxSnapshot, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSnapshot) + assert o._client is resource_client + assert o.id == 34 + assert o.name == "storage-box-snapshot1" + + +def assert_bound_storage_box_subaccount( + o: BoundStorageBoxSubaccount, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSubaccount) + assert o._client is resource_client + assert o.id == 45 + assert o.username == "u42-sub1" + + +class TestBoundStorageBox(BoundModelTestCase): + methods = [ + BoundStorageBox.update, + BoundStorageBox.delete, + BoundStorageBox.get_folders, + BoundStorageBox.change_protection, + BoundStorageBox.change_type, + BoundStorageBox.disable_snapshot_plan, + BoundStorageBox.enable_snapshot_plan, + BoundStorageBox.reset_password, + BoundStorageBox.rollback_snapshot, + BoundStorageBox.update_access_settings, + # Snapshots + BoundStorageBox.create_snapshot, + BoundStorageBox.get_snapshot_all, + BoundStorageBox.get_snapshot_by_id, + BoundStorageBox.get_snapshot_by_name, + BoundStorageBox.get_snapshot_list, + # Subaccounts + BoundStorageBox.create_subaccount, + BoundStorageBox.get_subaccount_all, + BoundStorageBox.get_subaccount_by_id, + BoundStorageBox.get_subaccount_by_username, + BoundStorageBox.get_subaccount_list, + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box1, + ) -> BoundStorageBox: + return BoundStorageBox(resource_client, data=storage_box1) + + def test_init(self, bound_model: BoundStorageBox, resource_client): + o = bound_model + + assert_bound_storage_box(o, resource_client) + + assert o.storage_box_type.id == 42 + assert o.storage_box_type.name == "bx11" + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.system == "FSN1-BX355" + assert o.server == "u1337.your-storagebox.de" + assert o.username == "u12345" + assert o.labels == {"key": "value"} + assert o.protection == {"delete": False} + assert o.snapshot_plan.max_snapshots == 20 + assert o.snapshot_plan.minute == 0 + assert o.snapshot_plan.hour == 7 + assert o.snapshot_plan.day_of_week == 7 + assert o.snapshot_plan.day_of_month is None + assert o.access_settings.reachable_externally is False + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is False + assert o.access_settings.webdav_enabled is False + assert o.access_settings.zfs_enabled is False + assert o.stats.size == 2342236717056 + assert o.stats.size_data == 2102612983808 + assert o.stats.size_snapshots == 239623733248 + assert o.status == "active" + assert o.created == isoparse("2025-01-30T23:55:00Z") + + +class TestBoundStorageBoxSnapshot(BoundModelTestCase): + methods = [ + (BoundStorageBoxSnapshot.update, {"client_method": "update_snapshot"}), + (BoundStorageBoxSnapshot.delete, {"client_method": "delete_snapshot"}), + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ) -> BoundStorageBoxSnapshot: + return BoundStorageBoxSnapshot(resource_client, data=storage_box_snapshot1) + + def test_init(self, bound_model: BoundStorageBoxSnapshot, resource_client): + o = bound_model + + assert_bound_storage_box_snapshot(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.description == "" + assert o.is_automatic is False + assert o.labels == {"key": "value"} + assert o.stats.size == 394957594 + assert o.stats.size_filesystem == 3949572745 + assert o.created == isoparse("2025-11-10T19:16:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + o = BoundStorageBoxSnapshot(resource_client, data={"id": 34, "storage_box": 42}) + + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert o.labels is not None + + +class TestBoundStorageBoxSubaccount(BoundModelTestCase): + methods = [ + ( + BoundStorageBoxSubaccount.update, + {"client_method": "update_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.delete, + {"client_method": "delete_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.change_home_directory, + {"client_method": "change_subaccount_home_directory"}, + ), + ( + BoundStorageBoxSubaccount.reset_password, + {"client_method": "reset_subaccount_password"}, + ), + ( + BoundStorageBoxSubaccount.update_access_settings, + {"client_method": "update_subaccount_access_settings"}, + ), + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ) -> BoundStorageBoxSubaccount: + return BoundStorageBoxSubaccount(resource_client, data=storage_box_subaccount1) + + def test_init(self, bound_model: BoundStorageBoxSubaccount, resource_client): + o = bound_model + + assert_bound_storage_box_subaccount(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.username == "u42-sub1" + assert o.description == "Required by foo" + assert o.server == "u42-sub1.your-storagebox.de" + assert o.home_directory == "tmp/" + assert o.access_settings.reachable_externally is True + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is True + assert o.access_settings.webdav_enabled is False + assert o.access_settings.readonly is False + assert o.labels == {"key": "value"} + assert o.created == isoparse("2025-11-10T19:18:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + o = BoundStorageBoxSubaccount( + resource_client, data={"id": 45, "storage_box": 42} + ) + + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert o.labels is not None + + +class TestStorageBoxClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_box": storage_box1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42", + ) + + assert_bound_storage_box(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"page": 1, "per_page": 10}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_boxes) == 2 + + result1 = result.storage_boxes[0] + result2 = result.storage_boxes[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "storage-box2" + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + method="GET", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "storage-box2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_boxes": [storage_box1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes", + params=params, + ) + + assert_bound_storage_box(result, resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + action1_running, + ): + request_mock.return_value = { + "storage_box": storage_box1, + "action": action1_running, + } + + result = resource_client.create( + name="storage-box1", + password="secret-password", + location=Location(name="fsn1"), + storage_box_type=StorageBoxType(name="bx11"), + ssh_keys=[], + access_settings=StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes", + json={ + "name": "storage-box1", + "password": "secret-password", + "location": "fsn1", + "storage_box_type": "bx11", + "ssh_keys": [], + "access_settings": { + "reachable_externally": True, + "samba_enabled": False, + "ssh_enabled": True, + }, + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box(result.storage_box, resource_client) + + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = { + "storage_box": storage_box1, + } + + result = resource_client.update( + StorageBox(id=42), + name="name", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42", + json={ + "name": "name", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box(result, resource_client) + + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete(StorageBox(id=42)) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {"path": "dir1/path"}, + {}, + ], + ) + def test_get_folders( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + params, + ): + request_mock.return_value = { + "folders": ["dir1", "dir2"], + } + + result = resource_client.get_folders(StorageBox(id=42), **params) + + request_mock.assert_called_with( + method="GET", url="/storage_boxes/42/folders", params=params + ) + + assert result.folders == ["dir1", "dir2"] + + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(StorageBox(id=42), delete=True) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_change_type( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + StorageBox(id=42), + StorageBoxType(name="bx21"), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_type", + json={"storage_box_type": "bx21"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_password( + StorageBox(id=42), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/reset_password", + json={"password": "password"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_access_settings( + StorageBox(id=42), + StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + webdav_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "webdav_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_rollback_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.rollback_snapshot( + StorageBox(id=42), + StorageBoxSnapshot(id=32), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/rollback_snapshot", + json={"snapshot": 32}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_disable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.disable_snapshot_plan( + StorageBox(id=42), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/disable_snapshot_plan", + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_enable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.enable_snapshot_plan( + StorageBox(id=42), + StorageBoxSnapshotPlan( + max_snapshots=10, + hour=3, + minute=30, + day_of_week=None, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/enable_snapshot_plan", + json={ + "max_snapshots": 10, + "hour": 3, + "minute": 30, + "day_of_week": None, + "day_of_month": None, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + # Snapshots + ########################################################################### + + def test_get_snapshot_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + result = resource_client.get_snapshot_by_id(StorageBox(42), 34) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_snapshot_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.snapshots) == 2 + + result1 = result.snapshots[0] + result2 = result.snapshots[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_snapshot_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_snapshot_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshots": [storage_box_snapshot1]} + + result = resource_client.get_snapshot_by_name( + StorageBox(42), "storage-box-snapshot1" + ) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots", + params={"name": "storage-box-snapshot1"}, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_create_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1: dict, + action1_running, + ): + request_mock.return_value = { + "snapshot": { + # Only a partial object is returned + key: storage_box_snapshot1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_snapshot( + StorageBox(42), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/snapshots", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.snapshot, BoundStorageBoxSnapshot) + assert result.snapshot._client is resource_client + assert result.snapshot.id == 34 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = { + "snapshot": storage_box_snapshot1, + } + + result = resource_client.update_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/snapshots/34", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_delete_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)) + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + # Subaccounts + ########################################################################### + + def test_get_subaccount_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + result = resource_client.get_subaccount_by_id(StorageBox(42), 45) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_subaccount_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.subaccounts) == 2 + + result1 = result.subaccounts[0] + result2 = result.subaccounts[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_subaccount_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_subaccount_by_username( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_username(StorageBox(42), "u42-sub1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"username": "u42-sub1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_create_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1: dict, + action1_running, + ): + request_mock.return_value = { + "subaccount": { + # Only a partial object is returned + key: storage_box_subaccount1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_subaccount( + StorageBox(42), + home_directory="tmp", + password="secret", + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + readonly=False, + ), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts", + json={ + "home_directory": "tmp", + "password": "secret", + "access_settings": { + "reachable_externally": True, + "ssh_enabled": True, + "readonly": False, + }, + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.subaccount, BoundStorageBoxSubaccount) + assert result.subaccount._client is resource_client + assert result.subaccount.id == 45 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = { + "subaccount": storage_box_subaccount1, + } + + result = resource_client.update_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/subaccounts/45", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_delete_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_change_subaccount_home_directory( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_subaccount_home_directory( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + home_directory="path", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/change_home_directory", + json={ + "home_directory": "path", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_subaccount_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_subaccount_password( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/reset_subaccount_password", + json={ + "password": "password", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_subaccount_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_subaccount_access_settings( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "samba_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) diff --git a/tests/unit/storage_boxes/test_domain.py b/tests/unit/storage_boxes/test_domain.py new file mode 100644 index 00000000..eef73e06 --- /dev/null +++ b/tests/unit/storage_boxes/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_boxes import StorageBox + + +@pytest.mark.parametrize( + "value", + [ + (StorageBox(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) From 98dce778ddeea81d6c6e5c4d3af874444ae29e47 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:10:29 +0100 Subject: [PATCH 142/213] chore(main): release v2.12.0 (#599) ### Storage Box API Experimental This release adds support for the [Storage Box API](https://docs.hetzner.cloud/reference/hetzner#storage-boxes). The Storage Box integration will be introduced as an **experimental** feature. This experimental phase is expected to last at least until **12 January 2026**. During this period, upcoming minor releases of the project may include breaking changes to features related to Storage Boxes. This release includes all changes from the recent [Storage Box API changelog](https://docs.hetzner.cloud/changelog#2025-10-21-storage-box-api-update) entry. #### Examples ```python response = client.storage_boxes.create( name="string", location=Location(name="fsn1"), storage_box_type=StorageBoxType(name="bx11"), labels={ "environment": "prod", "example.com/my": "label", "just-a-key": "", }, password="my-password", access_settings=StorageBoxAccessSettings( reachable_externally=False, samba_enabled=False, ssh_enabled=False, webdav_enabled=False, zfs_enabled=False, ), ssh_keys=[SSHKey(public_key="ssh-rsa AAAjjk76kgf...Xt")], ) response.action.wait_until_finished() storage_box = response.storage_box ``` ### Features - add update rrset records action to zone client (#597) - add support for Storage Boxes (#524) --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccf5eb3d..e3846c68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## [v2.12.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.12.0) + +### Storage Box API Experimental + +This release adds support for the [Storage Box API](https://docs.hetzner.cloud/reference/hetzner#storage-boxes). + +The Storage Box integration will be introduced as an **experimental** feature. This experimental phase is expected to last at least until **12 January 2026**. During this period, upcoming minor releases of the project may include breaking changes to features related to Storage Boxes. + +This release includes all changes from the recent [Storage Box API changelog](https://docs.hetzner.cloud/changelog#2025-10-21-storage-box-api-update) entry. + +#### Examples + +```python +response = client.storage_boxes.create( + name="string", + location=Location(name="fsn1"), + storage_box_type=StorageBoxType(name="bx11"), + labels={ + "environment": "prod", + "example.com/my": "label", + "just-a-key": "", + }, + password="my-password", + access_settings=StorageBoxAccessSettings( + reachable_externally=False, + samba_enabled=False, + ssh_enabled=False, + webdav_enabled=False, + zfs_enabled=False, + ), + ssh_keys=[SSHKey(public_key="ssh-rsa AAAjjk76kgf...Xt")], +) + +response.action.wait_until_finished() + +storage_box = response.storage_box +``` + +### Features + +- add update rrset records action to zone client (#597) +- add support for Storage Boxes (#524) + ## [v2.11.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.1) ### Bug Fixes diff --git a/hcloud/_version.py b/hcloud/_version.py index 25bb8326..77282e5e 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.11.1" # x-releaser-pleaser-version +__version__ = "2.12.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 328388d0..c07b12f0 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.11.1", # x-releaser-pleaser-version + version="2.12.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 6c4ce5ac7620a71a71faa49141d4b53f5c6eed11 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:41:23 +0100 Subject: [PATCH 143/213] ci: remove stale bot workflow (#605) --- .github/workflows/stale.yml | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .github/workflows/stale.yml diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index d9410ae9..00000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Close stale issues - -on: - schedule: - - cron: "30 12 * * *" - -jobs: - stale: - permissions: - issues: write - pull-requests: write - - uses: hetznercloud/.github/.github/workflows/stale.yml@main From 9e7cd39804a12e92f0b63682c9f1277f2a8f7680 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:47:37 +0100 Subject: [PATCH 144/213] chore(deps): update github artifact actions (major) (#607) 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 | |---|---|---|---| | [actions/download-artifact](https://redirect.github.com/actions/download-artifact) | action | major | `v6` -> `v7` | | [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) | action | major | `v5` -> `v6` | --- ### Release Notes
actions/download-artifact (actions/download-artifact) ### [`v7`](https://redirect.github.com/actions/download-artifact/compare/v6...v7) [Compare Source](https://redirect.github.com/actions/download-artifact/compare/v6...v7)
actions/upload-artifact (actions/upload-artifact) ### [`v6`](https://redirect.github.com/actions/upload-artifact/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. πŸ‘» **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] 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> --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 385950b8..78b5aaed 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@v5 + uses: actions/upload-artifact@v6 with: name: python-packages path: dist/ @@ -47,7 +47,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: python-packages path: dist/ From 0a46f0c354b9a34d7985bdbd695c174563144fab Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 16 Dec 2025 15:45:36 +0100 Subject: [PATCH 145/213] feat: add per primary ip actions list operations (#608) Add the per primary ip actions listing operations. --- hcloud/primary_ips/client.py | 110 +++++++++++++++++++++++++++++- tests/unit/actions/test_client.py | 4 -- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index eb5d30d1..4845a18d 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import BoundAction, ResourceActionsClient +from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePrimaryIPResponse, PrimaryIP @@ -26,6 +26,52 @@ def __init__(self, client: PrimaryIPsClient, data: dict, complete: bool = True): super().__init__(client, data, complete) + def get_actions_list( + self, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Primary IP. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param primary_ip: Primary IP to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Primary IP. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param primary_ip: Primary IP to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + def update( self, auto_delete: bool | None = None, @@ -115,6 +161,68 @@ def __init__(self, client: Client): super().__init__(client) self.actions = ResourceActionsClient(client, self._base_url) + def get_actions_list( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Primary IP. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param primary_ip: Primary IP to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}/actions", + method="GET", + params=params, + ) + actions = [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] + return ActionsPageResult(actions, Meta.parse_meta(response)) + + def get_actions( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Primary IP. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param primary_ip: Primary IP to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + primary_ip, + status=status, + sort=sort, + ) + def get_by_id(self, id: int) -> BoundPrimaryIP: """Returns a specific Primary IP object. diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 0b708cfb..2b9d8113 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -237,8 +237,6 @@ class TestResourceObjectActionsClient: @pytest.fixture(params=resources_with_actions.keys()) def resource(self, request): - if request.param == "primary_ips": - pytest.skip("not implemented yet") return request.param @pytest.fixture() @@ -322,8 +320,6 @@ class TestBoundModelActions: @pytest.fixture(params=resources_with_actions.keys()) def resource(self, request): - if request.param == "primary_ips": - pytest.skip("not implemented yet") return request.param @pytest.fixture() From 3df9e69f9bfd87d18b5eb891d1a543bdb0b380dc Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 16 Dec 2025 16:04:27 +0100 Subject: [PATCH 146/213] refactor: use base domain parse datetime helper (#606) Makes it easy to eventually swap `isoparse` with `datetime.fromisoformat` (first attempt was reverted in #231). Related to https://docs.hetzner.cloud/changelog#2025-12-09-api-timestamp-format-rfc3339 --- hcloud/actions/domain.py | 6 ++---- hcloud/certificates/domain.py | 8 +++----- hcloud/core/domain.py | 15 ++++++++++++++- hcloud/deprecation/domain.py | 8 ++------ hcloud/firewalls/domain.py | 4 +--- hcloud/floating_ips/domain.py | 4 +--- hcloud/images/domain.py | 6 ++---- hcloud/load_balancers/domain.py | 4 +--- hcloud/metrics/domain.py | 6 ++---- hcloud/networks/domain.py | 4 +--- hcloud/placement_groups/domain.py | 4 +--- hcloud/primary_ips/domain.py | 4 +--- hcloud/servers/domain.py | 4 +--- hcloud/ssh_keys/domain.py | 4 +--- hcloud/storage_boxes/domain.py | 8 +++----- hcloud/volumes/domain.py | 4 +--- hcloud/zones/domain.py | 10 ++-------- tests/unit/core/test_domain.py | 2 +- 18 files changed, 40 insertions(+), 65 deletions(-) diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index aa7f2fd9..fd14bfcf 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from .._exceptions import HCloudException from ..core import BaseDomain @@ -59,8 +57,8 @@ def __init__( self.status = status self.progress = progress - self.started = isoparse(started) if started else None - self.finished = isoparse(finished) if finished else None + self.started = self._parse_datetime(started) + self.finished = self._parse_datetime(finished) self.resources = resources self.error = error diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py index 6b231ed1..455ea4ad 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -69,9 +67,9 @@ def __init__( self.certificate = certificate self.domain_names = domain_names self.fingerprint = fingerprint - self.not_valid_before = isoparse(not_valid_before) if not_valid_before else None - self.not_valid_after = isoparse(not_valid_after) if not_valid_after else None - self.created = isoparse(created) if created else None + self.not_valid_before = self._parse_datetime(not_valid_before) + self.not_valid_after = self._parse_datetime(not_valid_after) + self.created = self._parse_datetime(created) self.labels = labels self.status = status diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index b2ac1972..245d658b 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,6 +1,9 @@ from __future__ import annotations -from typing import Any +from datetime import datetime +from typing import Any, overload + +from dateutil.parser import isoparse class BaseDomain: @@ -27,6 +30,16 @@ def __eq__(self, other: Any) -> bool: return False return True + @overload + def _parse_datetime(self, value: str) -> datetime: ... + @overload + def _parse_datetime(self, value: None) -> None: ... + + def _parse_datetime(self, value: str | None) -> datetime | None: + if value is None: + return None + return isoparse(value) + class DomainIdentityMixin: diff --git a/hcloud/deprecation/domain.py b/hcloud/deprecation/domain.py index 152a24e4..c34915b3 100644 --- a/hcloud/deprecation/domain.py +++ b/hcloud/deprecation/domain.py @@ -1,7 +1,5 @@ from __future__ import annotations -from dateutil.parser import isoparse - from ..core import BaseDomain @@ -28,7 +26,5 @@ def __init__( announced: str | None = None, unavailable_after: str | None = None, ): - self.announced = isoparse(announced) if announced else None - self.unavailable_after = ( - isoparse(unavailable_after) if unavailable_after else None - ) + self.announced = self._parse_datetime(announced) + self.unavailable_after = self._parse_datetime(unavailable_after) diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py index 991eb8ba..d644a051 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Any -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -46,7 +44,7 @@ def __init__( self.rules = rules self.applied_to = applied_to self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class FirewallRule(BaseDomain): diff --git a/hcloud/floating_ips/domain.py b/hcloud/floating_ips/domain.py index 1b02d8c0..a9114170 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -83,7 +81,7 @@ def __init__( self.blocked = blocked self.protection = protection self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.name = name diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index 497bedb4..ee610ff9 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -96,11 +94,11 @@ def __init__( self.id = id self.name = name self.type = type - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.description = description self.image_size = image_size self.disk_size = disk_size - self.deprecated = isoparse(deprecated) if deprecated else None + self.deprecated = self._parse_datetime(deprecated) self.bound_to = bound_to self.os_flavor = os_flavor self.os_version = os_version diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index fbedf650..8aa8b1f5 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -3,8 +3,6 @@ import warnings from typing import TYPE_CHECKING, Any, Literal -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -93,7 +91,7 @@ def __init__( ): self.id = id self.name = name - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.public_net = public_net self.private_net = private_net self.location = location diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py index c7e1f55a..69091d27 100644 --- a/hcloud/metrics/domain.py +++ b/hcloud/metrics/domain.py @@ -3,8 +3,6 @@ from datetime import datetime from typing import Literal -from dateutil.parser import isoparse - from ..core import BaseDomain TimeSeries = dict[str, dict[Literal["values"], list[tuple[float, str]]]] @@ -41,7 +39,7 @@ def __init__( step: float, time_series: TimeSeries, ): - self.start = isoparse(start) - self.end = isoparse(end) + self.start = self._parse_datetime(start) + self.end = self._parse_datetime(end) self.step = step self.time_series = time_series diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 44311143..63c0d7b7 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -3,8 +3,6 @@ import warnings from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -65,7 +63,7 @@ def __init__( ): self.id = id self.name = name - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.ip_range = ip_range self.subnets = subnets self.routes = routes diff --git a/hcloud/placement_groups/domain.py b/hcloud/placement_groups/domain.py index 43151404..59958ace 100644 --- a/hcloud/placement_groups/domain.py +++ b/hcloud/placement_groups/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -50,7 +48,7 @@ def __init__( self.labels = labels self.servers = servers self.type = type - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class CreatePlacementGroupResponse(BaseDomain): diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index 7cb0fa6f..e6217b01 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -84,7 +82,7 @@ def __init__( self.blocked = blocked self.protection = protection self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.name = name self.assignee_id = assignee_id self.assignee_type = assignee_type diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index df9c0a43..d94068eb 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Literal -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -135,7 +133,7 @@ def __init__( self.id = id self.name = name self.status = status - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.public_net = public_net self.server_type = server_type self.datacenter = datacenter diff --git a/hcloud/ssh_keys/domain.py b/hcloud/ssh_keys/domain.py index 8f8bddad..1d957ba1 100644 --- a/hcloud/ssh_keys/domain.py +++ b/hcloud/ssh_keys/domain.py @@ -1,7 +1,5 @@ from __future__ import annotations -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin @@ -46,4 +44,4 @@ def __init__( self.fingerprint = fingerprint self.public_key = public_key self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 7d94fcb2..85470954 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Any, Literal -from dateutil.parser import isoparse - from ..actions import BoundAction from ..core import BaseDomain, DomainIdentityMixin from ..locations import BoundLocation, Location @@ -82,7 +80,7 @@ def __init__( self.access_settings = access_settings self.stats = stats self.status = status - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class StorageBoxAccessSettings(BaseDomain): @@ -285,7 +283,7 @@ def __init__( self.is_automatic = is_automatic self.labels = labels self.storage_box = storage_box - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.stats = stats @@ -386,7 +384,7 @@ def __init__( self.access_settings = access_settings self.labels = labels self.storage_box = storage_box - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class StorageBoxSubaccountAccessSettings(BaseDomain): diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py index f2934373..4c4fcb7f 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -77,7 +75,7 @@ def __init__( self.id = id self.name = name self.server = server - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.location = location self.size = size self.linux_device = linux_device diff --git a/hcloud/zones/domain.py b/hcloud/zones/domain.py index cb80ef5c..ad24035f 100644 --- a/hcloud/zones/domain.py +++ b/hcloud/zones/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Any, Literal, TypedDict -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -78,7 +76,7 @@ def __init__( ): self.id = id self.name = name - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.mode = mode self.ttl = ttl self.labels = labels @@ -185,11 +183,7 @@ def __init__( ): self.assigned = assigned self.delegated = delegated - self.delegation_last_check = ( - isoparse(delegation_last_check) - if delegation_last_check is not None - else None - ) + self.delegation_last_check = self._parse_datetime(delegation_last_check) self.delegation_status = delegation_status diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index 73cab892..bfef1129 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -99,7 +99,7 @@ class ActionDomain(BaseDomain, DomainIdentityMixin): def __init__(self, id, name="name1", started=None): self.id = id self.name = name - self.started = isoparse(started) if started else None + self.started = self._parse_datetime(started) class SomeOtherDomain(BaseDomain): From 9c70221675a260834b7f4a93a07b2bb545f11800 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 19 Dec 2025 14:29:22 +0100 Subject: [PATCH 147/213] test: enable mypy strict mode (#611) - Increase type coverage - Fix invalid types Closes #479 --- hcloud/_client.py | 12 +++++----- hcloud/actions/client.py | 2 +- hcloud/actions/domain.py | 20 ++++++++++++---- hcloud/certificates/client.py | 9 +++++-- hcloud/core/client.py | 36 +++++++++++++++++++--------- hcloud/core/domain.py | 8 +++---- hcloud/datacenters/client.py | 4 ++-- hcloud/firewalls/client.py | 9 +++++-- hcloud/floating_ips/client.py | 9 +++++-- hcloud/floating_ips/domain.py | 11 ++++++--- hcloud/images/client.py | 8 +++++-- hcloud/images/domain.py | 8 +++++-- hcloud/isos/client.py | 2 +- hcloud/isos/domain.py | 5 ++-- hcloud/load_balancer_types/client.py | 2 +- hcloud/load_balancer_types/domain.py | 4 +++- hcloud/load_balancers/client.py | 9 +++++-- hcloud/load_balancers/domain.py | 12 ++++++---- hcloud/locations/client.py | 2 +- hcloud/networks/client.py | 9 +++++-- hcloud/networks/domain.py | 8 +++++-- hcloud/placement_groups/client.py | 2 +- hcloud/primary_ips/client.py | 11 ++++++--- hcloud/primary_ips/domain.py | 13 ++++++---- hcloud/rdns/__init__.py | 7 ++++++ hcloud/rdns/domain.py | 8 +++++++ hcloud/server_types/client.py | 4 ++-- hcloud/server_types/domain.py | 7 +++--- hcloud/servers/client.py | 9 +++++-- hcloud/servers/domain.py | 12 +++++++--- hcloud/ssh_keys/client.py | 2 +- hcloud/storage_box_types/client.py | 2 +- hcloud/storage_box_types/domain.py | 6 +++-- hcloud/storage_boxes/client.py | 20 ++++++++++------ hcloud/volumes/client.py | 9 +++++-- hcloud/volumes/domain.py | 8 +++++-- hcloud/zones/client.py | 13 +++++++--- pyproject.toml | 1 + 38 files changed, 230 insertions(+), 93 deletions(-) create mode 100644 hcloud/rdns/__init__.py create mode 100644 hcloud/rdns/domain.py diff --git a/hcloud/_client.py b/hcloud/_client.py index 2bf147ec..c42f4fc7 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -3,7 +3,7 @@ import time from http import HTTPStatus from random import uniform -from typing import Protocol +from typing import Any, Protocol import requests @@ -72,7 +72,7 @@ def exponential_backoff_function( """ def func(retries: int) -> float: - interval = base * multiplier**retries # Exponential backoff + interval: float = base * multiplier**retries # Exponential backoff interval = min(cap, interval) # Cap backoff if jitter: interval = uniform(base, interval) # Add jitter @@ -292,7 +292,7 @@ def request( # type: ignore[no-untyped-def] method: str, url: str, **kwargs, - ) -> dict: + ) -> dict[str, Any]: """Perform a request to the Hetzner Cloud API. :param method: Method to perform the request. @@ -345,7 +345,7 @@ def request( # type: ignore[no-untyped-def] method: str, url: str, **kwargs, - ) -> dict: + ) -> dict[str, Any]: """Perform a request to the provided URL. :param method: Method to perform the request. @@ -381,7 +381,7 @@ def request( # type: ignore[no-untyped-def] continue raise - def _read_response(self, response: requests.Response) -> dict: + def _read_response(self, response: requests.Response) -> dict[str, Any]: correlation_id = response.headers.get("X-Correlation-Id") payload = {} try: @@ -404,7 +404,7 @@ def _read_response(self, response: requests.Response) -> dict: correlation_id=correlation_id, ) - error: dict = payload["error"] + error: dict[str, Any] = payload["error"] raise APIException( code=error["code"], message=error["message"], diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index a0c0cc49..0e7f8461 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -11,7 +11,7 @@ from .._client import Client -class BoundAction(BoundModelBase, Action): +class BoundAction(BoundModelBase[Action], Action): _client: ActionsClient model = Action diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index fd14bfcf..b85aca99 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, TypedDict from .._exceptions import HCloudException from ..core import BaseDomain @@ -49,8 +49,8 @@ def __init__( progress: int | None = None, started: str | None = None, finished: str | None = None, - resources: list[dict] | None = None, - error: dict | None = None, + resources: list[ActionResource] | None = None, + error: ActionError | None = None, ): self.id = id self.command = command @@ -63,6 +63,17 @@ def __init__( self.error = error +class ActionResource(TypedDict): + id: int + type: str + + +class ActionError(TypedDict): + code: str + message: str + details: dict[str, Any] + + class ActionException(HCloudException): """A generic action exception""" @@ -80,7 +91,8 @@ def __init__(self, action: Action | BoundAction): extras.append(action.error["code"]) else: - extras.append(action.command) + if action.command is not None: + extras.append(action.command) extras.append(str(action.id)) message += f" ({', '.join(extras)})" diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 8b2a7ef7..851178e8 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -15,12 +15,17 @@ from .._client import Client -class BoundCertificate(BoundModelBase, Certificate): +class BoundCertificate(BoundModelBase[Certificate], Certificate): _client: CertificatesClient model = Certificate - def __init__(self, client: CertificatesClient, data: dict, complete: bool = True): + def __init__( + self, + client: CertificatesClient, + data: dict[str, Any], + complete: bool = True, + ): status = data.get("status") if status is not None: error_data = status.get("error") diff --git a/hcloud/core/client.py b/hcloud/core/client.py index f0e67a7f..86bb1bb7 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -2,11 +2,16 @@ import warnings from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar + +from .domain import BaseDomain if TYPE_CHECKING: from .._client import Client, ClientBase - from .domain import BaseDomain + from .domain import Meta + + +T = TypeVar("T") class ResourceClientBase: @@ -23,10 +28,10 @@ def __init__(self, client: Client): def _iter_pages( # type: ignore[no-untyped-def] self, - list_function: Callable, + list_function: Callable[..., tuple[list[T], Meta]], *args, **kwargs, - ) -> list: + ) -> list[T]: results = [] page = 1 @@ -46,7 +51,12 @@ def _iter_pages( # type: ignore[no-untyped-def] return results - def _get_first_by(self, list_function: Callable, *args, **kwargs): # type: ignore[no-untyped-def] + def _get_first_by( # type: ignore[no-untyped-def] + self, + list_function: Callable[..., tuple[list[T], Meta]], + *args, + **kwargs, + ) -> T | None: entities, _ = list_function(*args, **kwargs) return entities[0] if entities else None @@ -69,15 +79,18 @@ def __init__(self, client: Client): super().__init__(client) -class BoundModelBase: +Domain = TypeVar("Domain", bound=BaseDomain) + + +class BoundModelBase(Generic[Domain]): """Bound Model Base""" - model: type[BaseDomain] + model: type[Domain] def __init__( self, client: ResourceClientBase, - data: dict, + data: dict[str, Any], complete: bool = True, ): """ @@ -90,7 +103,7 @@ def __init__( """ self._client = client self.complete = complete - self.data_model = self.model.from_dict(data) + 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 @@ -103,9 +116,10 @@ def __getattr__(self, name: str): # type: ignore[no-untyped-def] value = getattr(self.data_model, name) return value - def _get_self(self) -> BoundModelBase: + def _get_self(self) -> BoundModelBase[Domain]: assert hasattr(self._client, "get_by_id") - return self._client.get_by_id(self.data_model.id) + assert hasattr(self.data_model, "id") + return self._client.get_by_id(self.data_model.id) # type: ignore def reload(self) -> None: """Reloads the model and tries to get all data from the API""" diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index 245d658b..de75ffc5 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -7,10 +7,10 @@ class BaseDomain: - __api_properties__: tuple + __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data: dict): # type: ignore[no-untyped-def] + def from_dict(cls, data: dict[str, Any]): # type: ignore[no-untyped-def] """ Build the domain object from the data dict. """ @@ -18,7 +18,7 @@ def from_dict(cls, data: dict): # type: ignore[no-untyped-def] return cls(**supported_data) def __repr__(self) -> str: - kwargs = [f"{key}={getattr(self, key)!r}" for key in self.__api_properties__] # type: ignore[var-annotated] + kwargs = [f"{key}={getattr(self, key)!r}" for key in self.__api_properties__] return f"{self.__class__.__qualname__}({', '.join(kwargs)})" def __eq__(self, other: Any) -> bool: @@ -119,7 +119,7 @@ def __init__(self, pagination: Pagination | None = None): self.pagination = pagination @classmethod - def parse_meta(cls, response: dict) -> Meta: + def parse_meta(cls, response: dict[str, Any]) -> Meta: """ If present, extract the meta details from the response and return a meta object. """ diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index ae338a5a..192abaf5 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -8,12 +8,12 @@ from .domain import Datacenter, DatacenterServerTypes -class BoundDatacenter(BoundModelBase, Datacenter): +class BoundDatacenter(BoundModelBase[Datacenter], Datacenter): _client: DatacentersClient model = Datacenter - def __init__(self, client: DatacentersClient, data: dict): + def __init__(self, client: DatacentersClient, data: dict[str, Any]): location = data.get("location") if location is not None: data["location"] = BoundLocation(client._parent.locations, location) diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index a088947a..3caf1be6 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -17,12 +17,17 @@ from .._client import Client -class BoundFirewall(BoundModelBase, Firewall): +class BoundFirewall(BoundModelBase[Firewall], Firewall): _client: FirewallsClient model = Firewall - def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): + def __init__( + self, + client: FirewallsClient, + data: dict[str, Any], + complete: bool = True, + ): rules = data.get("rules", []) if rules: rules = [ diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index ce28be3d..771a094a 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -13,12 +13,17 @@ from ..servers import BoundServer, Server -class BoundFloatingIP(BoundModelBase, FloatingIP): +class BoundFloatingIP(BoundModelBase[FloatingIP], FloatingIP): _client: FloatingIPsClient model = FloatingIP - def __init__(self, client: FloatingIPsClient, data: dict, complete: bool = True): + def __init__( + self, + client: FloatingIPsClient, + data: dict[str, Any], + complete: bool = True, + ): # pylint: disable=import-outside-toplevel from ..servers import BoundServer diff --git a/hcloud/floating_ips/domain.py b/hcloud/floating_ips/domain.py index a9114170..6d24e77f 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction from ..locations import BoundLocation + from ..rdns import DNSPtr from ..servers import BoundServer from .client import BoundFloatingIP @@ -63,10 +64,10 @@ def __init__( description: str | None = None, ip: str | None = None, server: BoundServer | None = None, - dns_ptr: list[dict] | None = None, + dns_ptr: list[DNSPtr] | None = None, home_location: BoundLocation | None = None, blocked: bool | None = None, - protection: dict | None = None, + protection: FloatingIPProtection | None = None, labels: dict[str, str] | None = None, created: str | None = None, name: str | None = None, @@ -85,6 +86,10 @@ def __init__( self.name = name +class FloatingIPProtection(TypedDict): + delete: bool + + class CreateFloatingIPResponse(BaseDomain): """Create Floating IP Response Domain diff --git a/hcloud/images/client.py b/hcloud/images/client.py index b9425b78..822ff14a 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -11,12 +11,16 @@ from .._client import Client -class BoundImage(BoundModelBase, Image): +class BoundImage(BoundModelBase[Image], Image): _client: ImagesClient model = Image - def __init__(self, client: ImagesClient, data: dict): + def __init__( + self, + client: ImagesClient, + data: dict[str, Any], + ): # pylint: disable=import-outside-toplevel from ..servers import BoundServer diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index ee610ff9..ee653628 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -87,7 +87,7 @@ def __init__( architecture: str | None = None, rapid_deploy: bool | None = None, created_from: Server | BoundServer | None = None, - protection: dict | None = None, + protection: ImageProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, ): @@ -110,6 +110,10 @@ def __init__( self.status = status +class ImageProtection(TypedDict): + delete: bool + + class CreateImageResponse(BaseDomain): """Create Image Response Domain diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index d5e44963..9c5ea5ac 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -6,7 +6,7 @@ from .domain import Iso -class BoundIso(BoundModelBase, Iso): +class BoundIso(BoundModelBase[Iso], Iso): _client: IsosClient model = Iso diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py index 2a5667e4..c3ebd662 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from typing import Any from warnings import warn from ..core import BaseDomain, DomainIdentityMixin @@ -45,7 +46,7 @@ def __init__( architecture: str | None = None, description: str | None = None, deprecated: str | None = None, # pylint: disable=unused-argument - deprecation: dict | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -67,4 +68,4 @@ def deprecated(self) -> datetime | None: ) if self.deprecation is None: return None - return self.deprecation.unavailable_after + return self.deprecation.unavailable_after # type: ignore[no-any-return] diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index fbfb08d1..b639a37d 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -6,7 +6,7 @@ from .domain import LoadBalancerType -class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): +class BoundLoadBalancerType(BoundModelBase[LoadBalancerType], LoadBalancerType): _client: LoadBalancerTypesClient model = LoadBalancerType diff --git a/hcloud/load_balancer_types/domain.py b/hcloud/load_balancer_types/domain.py index 9142cb58..8492ea7b 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ..core import BaseDomain, DomainIdentityMixin @@ -46,7 +48,7 @@ def __init__( max_services: int | None = None, max_targets: int | None = None, max_assigned_certificates: int | None = None, - prices: list[dict] | None = None, + prices: list[dict[str, Any]] | None = None, ): self.id = id self.name = name diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 2539101b..c0d373ce 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -40,13 +40,18 @@ from ..networks import Network -class BoundLoadBalancer(BoundModelBase, LoadBalancer): +class BoundLoadBalancer(BoundModelBase[LoadBalancer], LoadBalancer): _client: LoadBalancersClient model = LoadBalancer # pylint: disable=too-many-branches,too-many-locals - def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = True): + def __init__( + self, + client: LoadBalancersClient, + data: dict[str, Any], + complete: bool = True, + ): algorithm = data.get("algorithm") if algorithm: data["algorithm"] = LoadBalancerAlgorithm(type=algorithm["type"]) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 8aa8b1f5..a5b62b50 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Any, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -81,7 +81,7 @@ def __init__( algorithm: LoadBalancerAlgorithm | None = None, services: list[LoadBalancerService] | None = None, load_balancer_type: BoundLoadBalancerType | None = None, - protection: dict | None = None, + protection: LoadBalancerProtection | None = None, labels: dict[str, str] | None = None, targets: list[LoadBalancerTarget] | None = None, created: str | None = None, @@ -116,6 +116,10 @@ def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: return None +class LoadBalancerProtection(TypedDict): + delete: bool + + class LoadBalancerService(BaseDomain): """LoadBalancerService Domain @@ -334,7 +338,7 @@ def __init__( domain: str | None = None, path: str | None = None, response: str | None = None, - status_codes: list | None = None, + status_codes: list[str] | None = None, tls: bool | None = None, ): self.domain = domain @@ -357,7 +361,7 @@ def __init__( domain: str | None = None, path: str | None = None, response: str | None = None, - status_codes: list | None = None, + status_codes: list[str] | None = None, tls: bool | None = None, ): warnings.warn( diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index e6f685db..8c7b5e5e 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -6,7 +6,7 @@ from .domain import Location -class BoundLocation(BoundModelBase, Location): +class BoundLocation(BoundModelBase[Location], Location): _client: LocationsClient model = Location diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index abef2b63..438d4a98 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -10,12 +10,17 @@ from .._client import Client -class BoundNetwork(BoundModelBase, Network): +class BoundNetwork(BoundModelBase[Network], Network): _client: NetworksClient model = Network - def __init__(self, client: NetworksClient, data: dict, complete: bool = True): + def __init__( + self, + client: NetworksClient, + data: dict[str, Any], + complete: bool = True, + ): subnets = data.get("subnets", []) if subnets is not None: subnets = [NetworkSubnet.from_dict(subnet) for subnet in subnets] diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 63c0d7b7..cf6f1fc8 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 +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -58,7 +58,7 @@ def __init__( routes: list[NetworkRoute] | None = None, expose_routes_to_vswitch: bool | None = None, servers: list[BoundServer] | None = None, - protection: dict | None = None, + protection: NetworkProtection | None = None, labels: dict[str, str] | None = None, ): self.id = id @@ -73,6 +73,10 @@ def __init__( self.labels = labels +class NetworkProtection(TypedDict): + delete: bool + + class NetworkSubnet(BaseDomain): """Network Subnet Domain diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index 94939341..f59e7e6c 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -7,7 +7,7 @@ from .domain import CreatePlacementGroupResponse, PlacementGroup -class BoundPlacementGroup(BoundModelBase, PlacementGroup): +class BoundPlacementGroup(BoundModelBase[PlacementGroup], PlacementGroup): _client: PlacementGroupsClient model = PlacementGroup diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 4845a18d..f7f36a80 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -11,12 +11,17 @@ from ..datacenters import BoundDatacenter, Datacenter -class BoundPrimaryIP(BoundModelBase, PrimaryIP): +class BoundPrimaryIP(BoundModelBase[PrimaryIP], PrimaryIP): _client: PrimaryIPsClient model = PrimaryIP - def __init__(self, client: PrimaryIPsClient, data: dict, complete: bool = True): + def __init__( + self, + client: PrimaryIPsClient, + data: dict[str, Any], + complete: bool = True, + ): # pylint: disable=import-outside-toplevel from ..datacenters import BoundDatacenter @@ -307,7 +312,7 @@ def create( assignee_type: str | None = "server", assignee_id: int | None = None, auto_delete: bool | None = False, - labels: dict | None = None, + labels: dict[str, str] | None = None, ) -> CreatePrimaryIPResponse: """Creates a new Primary IP assigned to a server. diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index e6217b01..9a7e3d2b 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction from ..datacenters import BoundDatacenter + from ..rdns import DNSPtr from .client import BoundPrimaryIP @@ -63,11 +64,11 @@ def __init__( id: int | None = None, type: str | None = None, ip: str | None = None, - dns_ptr: list[dict] | None = None, + dns_ptr: list[DNSPtr] | None = None, datacenter: BoundDatacenter | None = None, blocked: bool | None = None, - protection: dict | None = None, - labels: dict[str, dict] | None = None, + protection: PrimaryIPProtection | None = None, + labels: dict[str, str] | None = None, created: str | None = None, name: str | None = None, assignee_id: int | None = None, @@ -89,6 +90,10 @@ def __init__( self.auto_delete = auto_delete +class PrimaryIPProtection(TypedDict): + delete: bool + + class CreatePrimaryIPResponse(BaseDomain): """Create Primary IP Response Domain diff --git a/hcloud/rdns/__init__.py b/hcloud/rdns/__init__.py new file mode 100644 index 00000000..116a632b --- /dev/null +++ b/hcloud/rdns/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .domain import DNSPtr + +__all__ = [ + "DNSPtr", +] diff --git a/hcloud/rdns/domain.py b/hcloud/rdns/domain.py new file mode 100644 index 00000000..0f8b0341 --- /dev/null +++ b/hcloud/rdns/domain.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import TypedDict + + +class DNSPtr(TypedDict): + ip: str + dns_ptr: str diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index db0ef54a..33f5ebda 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -7,7 +7,7 @@ from .domain import ServerType, ServerTypeLocation -class BoundServerType(BoundModelBase, ServerType): +class BoundServerType(BoundModelBase[ServerType], ServerType): _client: ServerTypesClient model = ServerType @@ -15,7 +15,7 @@ class BoundServerType(BoundModelBase, ServerType): def __init__( self, client: ServerTypesClient, - data: dict, + data: dict[str, Any], complete: bool = True, ): raw = data.get("locations") diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index ff9e3fd4..1c470636 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -1,6 +1,7 @@ from __future__ import annotations import warnings +from typing import Any from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo @@ -79,12 +80,12 @@ def __init__( cores: int | None = None, memory: int | None = None, disk: int | None = None, - prices: list[dict] | None = None, + prices: list[dict[str, Any]] | None = None, storage_type: str | None = None, cpu_type: str | None = None, architecture: str | None = None, deprecated: bool | None = None, - deprecation: dict | None = None, + deprecation: dict[str, Any] | None = None, included_traffic: int | None = None, locations: list[ServerTypeLocation] | None = None, ): @@ -191,7 +192,7 @@ def __init__( self, *, location: BoundLocation, - deprecation: dict | None, + deprecation: dict[str, Any] | None, ): self.location = location self.deprecation = ( diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 626c6c36..d5abb28d 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -48,13 +48,18 @@ from .domain import ServerCreatePublicNetwork -class BoundServer(BoundModelBase, Server): +class BoundServer(BoundModelBase[Server], Server): _client: ServersClient model = Server # pylint: disable=too-many-locals - def __init__(self, client: ServersClient, data: dict, complete: bool = True): + def __init__( + self, + client: ServersClient, + data: dict[str, Any], + complete: bool = True, + ): datacenter = data.get("datacenter") if datacenter is not None: data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index d94068eb..215fa9de 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -15,6 +15,7 @@ from ..networks import BoundNetwork, Network from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP, PrimaryIP + from ..rdns import DNSPtr from ..server_types import BoundServerType from ..volumes import BoundVolume from .client import BoundServer @@ -123,7 +124,7 @@ def __init__( outgoing_traffic: int | None = None, ingoing_traffic: int | None = None, included_traffic: int | None = None, - protection: dict | None = None, + protection: ServerProtection | None = None, labels: dict[str, str] | None = None, volumes: list[BoundVolume] | None = None, private_net: list[PrivateNet] | None = None, @@ -163,6 +164,11 @@ def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: return None +class ServerProtection(TypedDict): + rebuild: bool + delete: bool + + class CreateServerResponse(BaseDomain): """Create Server Response Domain @@ -387,7 +393,7 @@ def __init__( self, ip: str, blocked: bool, - dns_ptr: list, + dns_ptr: list[DNSPtr], ): self.ip = ip self.blocked = blocked diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index a565469b..fe2be196 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -6,7 +6,7 @@ from .domain import SSHKey -class BoundSSHKey(BoundModelBase, SSHKey): +class BoundSSHKey(BoundModelBase[SSHKey], SSHKey): _client: SSHKeysClient model = SSHKey diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index 05cb03e8..2f001e27 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -9,7 +9,7 @@ from .._client import Client -class BoundStorageBoxType(BoundModelBase, StorageBoxType): +class BoundStorageBoxType(BoundModelBase[StorageBoxType], StorageBoxType): _client: StorageBoxTypesClient model = StorageBoxType diff --git a/hcloud/storage_box_types/domain.py b/hcloud/storage_box_types/domain.py index b807ce27..dc90a8d4 100644 --- a/hcloud/storage_box_types/domain.py +++ b/hcloud/storage_box_types/domain.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any + from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo @@ -33,8 +35,8 @@ def __init__( automatic_snapshot_limit: int | None = None, subaccounts_limit: int | None = None, size: int | None = None, - prices: list[dict] | None = None, - deprecation: dict | None = None, + prices: list[dict[str, Any]] | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 0cb3c2db..f81e3c81 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -29,7 +29,7 @@ from .._client import Client -class BoundStorageBox(BoundModelBase, StorageBox): +class BoundStorageBox(BoundModelBase[StorageBox], StorageBox): _client: StorageBoxesClient model = StorageBox @@ -322,7 +322,7 @@ def get_snapshot_by_id( def get_snapshot_by_name( self, name: str, - ) -> BoundStorageBoxSnapshot: + ) -> BoundStorageBoxSnapshot | None: """ Returns a single Snapshot from a Storage Box. @@ -438,7 +438,7 @@ def get_subaccount_by_id( def get_subaccount_by_username( self, username: str, - ) -> BoundStorageBoxSubaccount: + ) -> BoundStorageBoxSubaccount | None: """ Returns a single Subaccount from a Storage Box. @@ -537,7 +537,7 @@ def create_subaccount( ) -class BoundStorageBoxSnapshot(BoundModelBase, StorageBoxSnapshot): +class BoundStorageBoxSnapshot(BoundModelBase[StorageBoxSnapshot], StorageBoxSnapshot): _client: StorageBoxesClient model = StorageBoxSnapshot @@ -561,6 +561,8 @@ def __init__( super().__init__(client, data, complete) def _get_self(self) -> BoundStorageBoxSnapshot: + assert self.data_model.storage_box is not None + assert self.data_model.id is not None return self._client.get_snapshot_by_id( self.data_model.storage_box, self.data_model.id, @@ -603,7 +605,9 @@ def delete( return self._client.delete_snapshot(self) -class BoundStorageBoxSubaccount(BoundModelBase, StorageBoxSubaccount): +class BoundStorageBoxSubaccount( + BoundModelBase[StorageBoxSubaccount], StorageBoxSubaccount +): _client: StorageBoxesClient model = StorageBoxSubaccount @@ -627,6 +631,8 @@ def __init__( super().__init__(client, data, complete) def _get_self(self) -> BoundStorageBoxSubaccount: + assert self.data_model.storage_box is not None + assert self.data_model.id is not None return self._client.get_subaccount_by_id( self.data_model.storage_box, self.data_model.id, @@ -1279,7 +1285,7 @@ def get_snapshot_by_name( self, storage_box: StorageBox | BoundStorageBox, name: str, - ) -> BoundStorageBoxSnapshot: + ) -> BoundStorageBoxSnapshot | None: """ Returns a single Snapshot from a Storage Box. @@ -1500,7 +1506,7 @@ def get_subaccount_by_username( self, storage_box: StorageBox | BoundStorageBox, username: str, - ) -> BoundStorageBoxSubaccount: + ) -> BoundStorageBoxSubaccount | None: """ Returns a single Subaccount from a Storage Box. diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 9d1cded6..2e36fd13 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -13,12 +13,17 @@ from ..servers import BoundServer, Server -class BoundVolume(BoundModelBase, Volume): +class BoundVolume(BoundModelBase[Volume], Volume): _client: VolumesClient model = Volume - def __init__(self, client: VolumesClient, data: dict, complete: bool = True): + def __init__( + self, + client: VolumesClient, + data: dict[str, Any], + complete: bool = True, + ): location = data.get("location") if location is not None: data["location"] = BoundLocation(client._parent.locations, location) diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py index 4c4fcb7f..1e2e4379 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -68,7 +68,7 @@ def __init__( size: int | None = None, linux_device: str | None = None, format: str | None = None, - protection: dict | None = None, + protection: VolumeProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, ): @@ -85,6 +85,10 @@ def __init__( self.status = status +class VolumeProtection(TypedDict): + delete: bool + + class CreateVolumeResponse(BaseDomain): """Create Volume Response Domain diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index b3346042..3a1d5c7b 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -23,7 +23,7 @@ from .._client import Client -class BoundZone(BoundModelBase, Zone): +class BoundZone(BoundModelBase[Zone], Zone): _client: ZonesClient model = Zone @@ -405,12 +405,17 @@ def set_rrset_records( return self._client.set_rrset_records(rrset=rrset, records=records) -class BoundZoneRRSet(BoundModelBase, ZoneRRSet): +class BoundZoneRRSet(BoundModelBase[ZoneRRSet], ZoneRRSet): _client: ZonesClient model = ZoneRRSet - def __init__(self, client: ZonesClient, data: dict, complete: bool = True): + def __init__( + self, + client: ZonesClient, + data: dict[str, Any], + complete: bool = True, + ): raw = data.get("zone") if raw is not None: data["zone"] = BoundZone(client, data={"id": raw}, complete=False) @@ -422,6 +427,8 @@ def __init__(self, client: ZonesClient, data: dict, complete: bool = True): super().__init__(client, data, complete) def _get_self(self) -> BoundZoneRRSet: + assert self.data_model.zone is not None + assert self.data_model.type is not None return self._client.get_rrset( self.data_model.zone, self.data_model.name, diff --git a/pyproject.toml b/pyproject.toml index b35f889d..cbf37daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ combine_as_imports = true add_imports = ["from __future__ import annotations"] [tool.mypy] +strict = true disallow_untyped_defs = true implicit_reexport = false From 83581b30cf3501140bcbc7c19f212e323aed6026 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 19 Dec 2025 14:38:00 +0100 Subject: [PATCH 148/213] ci: add link check workflow (#612) --- .github/workflows/links.yml | 37 +++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 6 +++--- README.md | 4 ++-- docs/upgrading.md | 10 +++++----- hcloud/networks/domain.py | 2 +- hcloud/zones/client.py | 6 +++--- 6 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/links.yml diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 00000000..a0ac87c3 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,37 @@ +name: Links + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "43 12 * * *" + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + + - uses: actions/cache@v4 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - uses: lycheeverse/lychee-action@v2 + with: + fail: true + args: > + --root-dir ${{ github.workspace }} + --verbose + --no-progress + --cache + --max-cache-age 2d + --include-fragments + --exclude 'api.hetzner.cloud' + --exclude 'api.hetzner.com' + --exclude 'codecov.io' + --exclude 'github.com' + '**/*.md' + '**/*.py' diff --git a/CHANGELOG.md b/CHANGELOG.md index e3846c68..325fd316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,7 @@ storage_box = response.storage_box The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. -To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process). +To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process/). See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for more details. @@ -83,7 +83,7 @@ See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for This release adds support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). -The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta) for more details. +The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta/) for more details. Future minor releases of this project may include breaking changes for features that are related to the DNS API. @@ -782,7 +782,7 @@ Fix: Ignore unknown fields in API response instead of raising an error - First stable release. - You can find the documentation under https://hcloud-python.readthedocs.io/en/latest/ + You can find the documentation under https://hcloud-python.readthedocs.io/en/stable/ ## 0.1.0 (2018-12-20) diff --git a/README.md b/README.md index 18876469..6cab8a9a 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,12 @@ [![](https://github.com/hetznercloud/hcloud-python/actions/workflows/test.yml/badge.svg)](https://github.com/hetznercloud/hcloud-python/actions/workflows/test.yml) [![](https://github.com/hetznercloud/hcloud-python/actions/workflows/lint.yml/badge.svg)](https://github.com/hetznercloud/hcloud-python/actions/workflows/lint.yml) [![](https://codecov.io/github/hetznercloud/hcloud-python/graph/badge.svg?token=3YGRqB5t1L)](https://codecov.io/github/hetznercloud/hcloud-python/tree/main) -[![](https://readthedocs.org/projects/hcloud-python/badge/?version=latest)](https://hcloud-python.readthedocs.io) +[![](https://app.readthedocs.org/projects/hcloud-python/badge/?version=latest)](https://hcloud-python.readthedocs.io/en/stable/) [![](https://img.shields.io/pypi/pyversions/hcloud.svg)](https://pypi.org/project/hcloud/) Official Hetzner Cloud python library. -The library's documentation is available at [hcloud-python.readthedocs.io](https://hcloud-python.readthedocs.io), the public API documentation is available at [docs.hetzner.cloud](https://docs.hetzner.cloud). +The library's documentation is available at [hcloud-python.readthedocs.io](https://hcloud-python.readthedocs.io/en/stable/), the public API documentation is available at [docs.hetzner.cloud](https://docs.hetzner.cloud). > [!IMPORTANT] > Make sure to follow our API changelog available at diff --git a/docs/upgrading.md b/docs/upgrading.md index dd0c8ce2..fbf79db8 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -20,7 +20,7 @@ Before upgrading, make sure to resolve any deprecation warnings. +from hcloud import Client ``` -- [#398](https://github.com/hetznercloud/hcloud-python/pull/398): The [`Client.poll_interval`](#hcloud.Client) property is now private, make sure to configure it while creating the [`Client`](#hcloud.Client): +- [#398](https://github.com/hetznercloud/hcloud-python/pull/398): The [`Client.poll_interval`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client) property is now private, make sure to configure it while creating the [`Client`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client): ```diff -client = Client(token=token) @@ -31,7 +31,7 @@ Before upgrading, make sure to resolve any deprecation warnings. +) ``` -- [#400](https://github.com/hetznercloud/hcloud-python/pull/400): The [`Client.request`](#hcloud.Client.request) method now returns an empty dict instead of an empty string when the API response is empty: +- [#400](https://github.com/hetznercloud/hcloud-python/pull/400): The [`Client.request`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client.request) method now returns an empty dict instead of an empty string when the API response is empty: ```diff response = client.request(method="DELETE", url="/primary_ips/123456") @@ -39,7 +39,7 @@ Before upgrading, make sure to resolve any deprecation warnings. +assert response == {} ``` -- [#402](https://github.com/hetznercloud/hcloud-python/pull/402): In the [`Client.isos.get_list`](#hcloud.isos.client.IsosClient.get_list) and [`Client.isos.get_all`](#hcloud.isos.client.IsosClient.get_all) methods, the deprecated `include_wildcard_architecture` argument was removed, make sure to use the `include_architecture_wildcard` argument instead: +- [#402](https://github.com/hetznercloud/hcloud-python/pull/402): In the [`Client.isos.get_list`](https://hcloud-python.readthedocs.io/en/stable/api.clients.isos.html#hcloud.isos.client.IsosClient.get_list) and [`Client.isos.get_all`](https://hcloud-python.readthedocs.io/en/stable/api.clients.isos.html#hcloud.isos.client.IsosClient.get_all) methods, the deprecated `include_wildcard_architecture` argument was removed, make sure to use the `include_architecture_wildcard` argument instead: ```diff client.isos.get_all( @@ -48,7 +48,7 @@ Before upgrading, make sure to resolve any deprecation warnings. ) ``` -- [#363](https://github.com/hetznercloud/hcloud-python/pull/363): In the [`Client.primary_ips.create`](#hcloud.primary_ips.client.PrimaryIPsClient.create) method, the `datacenter` argument was moved after `name` argument and is now optional: +- [#363](https://github.com/hetznercloud/hcloud-python/pull/363): In the [`Client.primary_ips.create`](https://hcloud-python.readthedocs.io/en/stable/api.clients.primary_ips.html#hcloud.primary_ips.client.PrimaryIPsClient.create) method, the `datacenter` argument was moved after `name` argument and is now optional: ```diff client.primary_ips.create( @@ -68,7 +68,7 @@ Before upgrading, make sure to resolve any deprecation warnings. ) ``` -- [#406](https://github.com/hetznercloud/hcloud-python/pull/406): In the [`Client.servers.rebuild`](#hcloud.servers.client.ServersClient.rebuild) method, the single action return value was deprecated and is now removed. The method now returns a full response wrapping the action and an optional root password: +- [#406](https://github.com/hetznercloud/hcloud-python/pull/406): In the [`Client.servers.rebuild`](https://hcloud-python.readthedocs.io/en/stable/api.clients.servers.html#hcloud.servers.client.ServersClient.rebuild) method, the single action return value was deprecated and is now removed. The method now returns a full response wrapping the action and an optional root password: ```diff -action = client.servers.rebuild(server, image) diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index cf6f1fc8..9f14a579 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -115,7 +115,7 @@ def TYPE_SERVER(self) -> str: # pylint: disable=invalid-name """ Used to connect cloud servers and load balancers with dedicated servers. - See https://docs.hetzner.com/cloud/networks/connect-dedi-vswitch/ + See https://docs.hetzner.com/networking/networks/connect-dedi-vswitch/ """ __api_properties__ = ("type", "ip_range", "network_zone", "gateway", "vswitch_id") diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index 3a1d5c7b..1e5383b1 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -367,7 +367,7 @@ def update_rrset_records( """ Updates records in a ZoneRRSet. - See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset :param rrset: RRSet to update. :param records: Records to update in the RRSet. @@ -508,7 +508,7 @@ def update_rrset_records( """ Updates records in a ZoneRRSet. - See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset :param records: Records to update in the RRSet. """ @@ -1215,7 +1215,7 @@ def update_rrset_records( """ Updates records in a ZoneRRSet. - See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-to-an-rrset + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset :param rrset: RRSet to update. :param records: Records to update in the RRSet. From 54198a750b8710fc11e75c4b1f4b9a3484f5b47f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:45:31 +0100 Subject: [PATCH 149/213] chore(deps): update actions/checkout action to v6 (#615) 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 | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` -> `v6` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 a0ac87c3..2d918f55 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/cache@v4 with: From c314c8a9a104a127b903c38bc858057807fa4fda Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:45:42 +0100 Subject: [PATCH 150/213] chore(deps): update actions/cache action to v5 (#614) 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 | |---|---|---|---| | [actions/cache](https://redirect.github.com/actions/cache) | action | major | `v4` -> `v5` | --- ### Release Notes
actions/cache (actions/cache) ### [`v5`](https://redirect.github.com/actions/cache/compare/v4...v5) [Compare Source](https://redirect.github.com/actions/cache/compare/v4...v5)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- .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 2d918f55..6c1a78ec 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/cache@v4 + - uses: actions/cache@v5 with: path: .lycheecache key: cache-lychee-${{ github.sha }} From d21d66cbb57fda30a22b3a16267bc11aa4ac2954 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 19 Dec 2025 15:16:56 +0100 Subject: [PATCH 151/213] feat: deprecate datacenter in `primary ips` and `servers` (#609) See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters --- hcloud/primary_ips/client.py | 24 ++++++++++++-- hcloud/primary_ips/domain.py | 45 ++++++++++++++++++++++++--- hcloud/servers/client.py | 20 +++++++++--- hcloud/servers/domain.py | 41 ++++++++++++++++++++++-- tests/unit/conftest.py | 17 +++++++--- tests/unit/primary_ips/test_client.py | 28 ++++++++++------- tests/unit/servers/test_client.py | 26 +++++++++------- 7 files changed, 159 insertions(+), 42 deletions(-) diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index f7f36a80..cc26e55e 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any, NamedTuple from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient @@ -9,6 +10,7 @@ if TYPE_CHECKING: from .._client import Client from ..datacenters import BoundDatacenter, Datacenter + from ..locations import BoundLocation, Location class BoundPrimaryIP(BoundModelBase[PrimaryIP], PrimaryIP): @@ -24,10 +26,15 @@ def __init__( ): # pylint: disable=import-outside-toplevel from ..datacenters import BoundDatacenter + from ..locations import BoundLocation - datacenter = data.get("datacenter", {}) - if datacenter: - data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) + raw = data.get("datacenter", {}) + if raw: + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) + + raw = data.get("location", {}) + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) super().__init__(client, data, complete) @@ -309,6 +316,7 @@ def create( type: str, name: str, datacenter: Datacenter | BoundDatacenter | None = None, + location: Location | BoundLocation | None = None, assignee_type: str | None = "server", assignee_id: int | None = None, auto_delete: bool | None = False, @@ -319,6 +327,7 @@ 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) :param auto_delete: bool (optional) @@ -333,7 +342,16 @@ 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: data["assignee_id"] = assignee_id if labels is not None: diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index 9a7e3d2b..58cd596f 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -7,6 +8,7 @@ if TYPE_CHECKING: from ..actions import BoundAction from ..datacenters import BoundDatacenter + from ..locations import BoundLocation from ..rdns import DNSPtr from .client import BoundPrimaryIP @@ -23,7 +25,15 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): :param dns_ptr: List[Dict] Array of reverse DNS entries :param datacenter: :class:`Datacenter ` - Datacenter the Primary IP was created in. + 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 Whether the IP is blocked :param protection: dict @@ -42,12 +52,12 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): Delete the Primary IP when the Assignee it is assigned to is deleted. """ - __api_properties__ = ( + __properties__ = ( "id", "ip", "type", "dns_ptr", - "datacenter", + "location", "blocked", "protection", "labels", @@ -57,7 +67,14 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): "assignee_type", "auto_delete", ) - __slots__ = __api_properties__ + __api_properties__ = ( + *__properties__, + "datacenter", + ) + __slots__ = ( + *__properties__, + "_datacenter", + ) def __init__( self, @@ -66,6 +83,7 @@ def __init__( 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, labels: dict[str, str] | None = None, @@ -80,6 +98,7 @@ def __init__( self.ip = ip self.dns_ptr = dns_ptr self.datacenter = datacenter + self.location = location self.blocked = blocked self.protection = protection self.labels = labels @@ -89,6 +108,24 @@ 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 d5abb28d..e65e4e08 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from datetime import datetime from typing import TYPE_CHECKING, Any, NamedTuple @@ -12,6 +13,7 @@ from ..floating_ips import BoundFloatingIP from ..images import BoundImage, CreateImageResponse from ..isos import BoundIso +from ..locations import BoundLocation, Location from ..metrics import Metrics from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP @@ -39,7 +41,6 @@ from ..firewalls import Firewall from ..images import Image from ..isos import Iso - from ..locations import BoundLocation, Location from ..networks import BoundNetwork, Network from ..placement_groups import PlacementGroup from ..server_types import ServerType @@ -60,9 +61,13 @@ def __init__( data: dict[str, Any], complete: bool = True, ): - datacenter = data.get("datacenter") - if datacenter is not None: - data["datacenter"] = BoundDatacenter(client._parent.datacenters, datacenter) + raw = data.get("datacenter") + if raw: + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) + + raw = data.get("location") + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) volumes = data.get("volumes", []) if volumes: @@ -662,6 +667,13 @@ 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] diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index 215fa9de..1a052886 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,5 +1,6 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -11,6 +12,7 @@ from ..floating_ips import BoundFloatingIP from ..images import BoundImage from ..isos import BoundIso + from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork, Network from ..placement_groups import BoundPlacementGroup @@ -36,6 +38,12 @@ class Server(BaseDomain, DomainIdentityMixin): 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 :param rescue_enabled: bool @@ -81,13 +89,13 @@ class Server(BaseDomain, DomainIdentityMixin): STATUS_UNKNOWN = "unknown" """Server Status unknown""" - __api_properties__ = ( + __properties__ = ( "id", "name", "status", "public_net", "server_type", - "datacenter", + "location", "image", "iso", "rescue_enabled", @@ -104,7 +112,14 @@ class Server(BaseDomain, DomainIdentityMixin): "primary_disk_size", "placement_group", ) - __slots__ = __api_properties__ + __api_properties__ = ( + *__properties__, + "datacenter", + ) + __slots__ = ( + *__properties__, + "_datacenter", + ) # pylint: disable=too-many-locals def __init__( @@ -116,6 +131,7 @@ def __init__( 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, rescue_enabled: bool | None = None, @@ -138,6 +154,7 @@ def __init__( self.public_net = public_net self.server_type = server_type self.datacenter = datacenter + self.location = location self.image = image self.iso = iso self.rescue_enabled = rescue_enabled @@ -163,6 +180,24 @@ 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/conftest.py b/tests/unit/conftest.py index 0d2a8ecb..65446f4a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import warnings from collections.abc import Callable from typing import ClassVar, TypedDict from unittest import mock @@ -185,11 +186,17 @@ def test_method_list(self, bound_model): members_count = 0 members_missing = [] - for name, member in inspect.getmembers( - bound_model, - lambda m: inspect.ismethod(m) - and m.__func__ in bound_model.__class__.__dict__.values(), - ): + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + + members = inspect.getmembers( + bound_model, + lambda m: inspect.ismethod(m) + and m.__func__ in bound_model.__class__.__dict__.values(), + ) + + for name, member in members: # Ignore private methods if name.startswith("_"): continue diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py index 8886a01e..a4ffcc3a 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -45,14 +45,17 @@ def test_init(self, primary_ip_response): assert bound_primary_ip.assignee_id == 17 assert bound_primary_ip.assignee_type == "server" - assert isinstance(bound_primary_ip.datacenter, BoundDatacenter) - assert bound_primary_ip.datacenter.id == 42 - assert bound_primary_ip.datacenter.name == "fsn1-dc8" - assert bound_primary_ip.datacenter.description == "Falkenstein DC Park 8" - assert bound_primary_ip.datacenter.location.country == "DE" - assert bound_primary_ip.datacenter.location.city == "Falkenstein" - assert bound_primary_ip.datacenter.location.latitude == 50.47612 - assert bound_primary_ip.datacenter.location.longitude == 12.370071 + with pytest.deprecated_call(): + datacenter = bound_primary_ip.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 class TestPrimaryIPsClient: @@ -132,9 +135,12 @@ def test_create_with_datacenter( ): request_mock.return_value = primary_ip_response - response = primary_ips_client.create( - type="ipv6", name="my-resource", datacenter=Datacenter(name="datacenter") - ) + with pytest.deprecated_call(): + response = primary_ips_client.create( + type="ipv6", + name="my-resource", + datacenter=Datacenter(name="datacenter"), + ) request_mock.assert_called_with( method="POST", diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index ebf1da24..0a09544d 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -100,12 +100,13 @@ 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 - assert isinstance(bound_server.datacenter, BoundDatacenter) - assert ( - bound_server.datacenter._client == bound_server._client._parent.datacenters - ) - assert bound_server.datacenter.id == 1 - assert bound_server.datacenter.complete is True + 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 ( @@ -288,12 +289,13 @@ def test_create_with_datacenter( ): request_mock.return_value = response_create_simple_server - response = servers_client.create( - "my-server", - server_type=ServerType(name="cx11"), - image=Image(id=4711), - datacenter=Datacenter(id=1), - ) + 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", From ae807f273aac8b39a2507c610f38936c27f91482 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 19 Dec 2025 15:23:46 +0100 Subject: [PATCH 152/213] refactor: improve actions methods and typing (#613) - Deduplicate request code and use a new `ResourceClientBaseActionsMixin` mixin. - Add types for the list filters. - Make all list actions doc string consistent. --- hcloud/actions/__init__.py | 4 + hcloud/actions/client.py | 151 ++++++++++++++++++++------------ hcloud/actions/domain.py | 10 ++- hcloud/certificates/client.py | 111 ++++++++++------------- hcloud/firewalls/client.py | 112 ++++++++++------------- hcloud/floating_ips/client.py | 111 ++++++++++------------- hcloud/images/client.py | 112 +++++++++++------------ hcloud/load_balancers/client.py | 111 ++++++++++------------- hcloud/networks/client.py | 111 ++++++++++------------- hcloud/primary_ips/client.py | 93 +++++++++----------- hcloud/servers/client.py | 111 ++++++++++------------- hcloud/storage_boxes/client.py | 78 ++++++++--------- hcloud/volumes/client.py | 111 ++++++++++------------- hcloud/zones/client.py | 88 +++++++++---------- 14 files changed, 615 insertions(+), 699 deletions(-) diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index 8a96e5de..653ad9bd 100644 --- a/hcloud/actions/__init__.py +++ b/hcloud/actions/__init__.py @@ -2,6 +2,7 @@ from .client import ( ActionsClient, + ActionSort, ActionsPageResult, BoundAction, ResourceActionsClient, @@ -10,15 +11,18 @@ Action, ActionException, ActionFailedException, + ActionStatus, ActionTimeoutException, ) __all__ = [ "Action", + "ActionStatus", "ActionException", "ActionFailedException", "ActionTimeoutException", "ActionsClient", + "ActionSort", "ActionsPageResult", "BoundAction", "ResourceActionsClient", diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index 0e7f8461..26924ec1 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -2,10 +2,10 @@ import time import warnings -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Literal, NamedTuple from ..core import BoundModelBase, Meta, ResourceClientBase -from .domain import Action, ActionFailedException, ActionTimeoutException +from .domain import Action, ActionFailedException, ActionStatus, ActionTimeoutException if TYPE_CHECKING: from .._client import Client @@ -45,12 +45,78 @@ def wait_until_finished(self, max_retries: int | None = None) -> None: raise ActionFailedException(action=self) +ActionSort = Literal[ + "id", + "id:asc", + "id:desc", + "command", + "command:asc", + "command:desc", + "status", + "status:asc", + "status:desc", + "started", + "started:asc", + "started:desc", + "finished", + "finished:asc", + "finished:desc", +] + + class ActionsPageResult(NamedTuple): actions: list[BoundAction] meta: Meta -class ResourceActionsClient(ResourceClientBase): +class ResourceClientBaseActionsMixin(ResourceClientBase): + def _get_action_by_id( + self, + base_url: str, + id: int, + ) -> BoundAction: + response = self._client.request( + method="GET", + url=f"{base_url}/actions/{id}", + ) + return BoundAction( + client=self._parent.actions, + data=response["action"], + ) + + def _get_actions_list( + self, + base_url: str, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{base_url}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + +class ResourceActionsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _resource: str def __init__(self, client: ResourceClientBase | Client, resource: str | None): @@ -66,69 +132,46 @@ def __init__(self, client: ResourceClientBase | Client, resource: str | None): self._resource = resource or "" def get_by_id(self, id: int) -> BoundAction: - """Get a specific action by its ID. + """ + Returns a specific Action by its ID. - :param id: int - :return: :class:`BoundAction ` + :param id: ID of the Action. """ - response = self._client.request( - url=f"{self._resource}/actions/{id}", - method="GET", - ) - return BoundAction(self._parent.actions, response["action"]) + return self._get_action_by_id(self._resource, id) def get_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Get a list of actions. - - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions. - response = self._client.request( - url=f"{self._resource}/actions", - method="GET", - params=params, + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + self._resource, + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_all( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Get all actions. + """ + Returns all Actions. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages(self.get_list, status=status, sort=sort) @@ -139,8 +182,8 @@ def __init__(self, client: Client): def get_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: @@ -162,8 +205,8 @@ def get_list( def get_all( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ .. deprecated:: 1.28 diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index b85aca99..99267599 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict from .._exceptions import HCloudException from ..core import BaseDomain @@ -8,6 +8,12 @@ if TYPE_CHECKING: from .client import BoundAction +ActionStatus = Literal[ + "running", + "success", + "error", +] + class Action(BaseDomain): """Action Domain @@ -45,7 +51,7 @@ def __init__( self, id: int, command: str | None = None, - status: str | None = None, + status: ActionStatus | None = None, progress: int | None = None, started: str | None = None, finished: str | None = None, diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index 851178e8..cf89196d 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( Certificate, @@ -41,22 +48,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Certificate. + """ + Returns a paginated list of Actions for a Certificate. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -68,16 +71,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Certificate. + """ + Returns all Actions for a Certificate. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions( self, @@ -122,7 +123,10 @@ class CertificatesPageResult(NamedTuple): meta: Meta -class CertificatesClient(ResourceClientBase): +class CertificatesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/certificates" actions: ResourceActionsClient @@ -311,59 +315,40 @@ def delete(self, certificate: Certificate | BoundCertificate) -> bool: def get_actions_list( self, certificate: Certificate | BoundCertificate, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Certificate. - - :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Certificate. - response = self._client.request( - url=f"{self._base_url}/{certificate.id}/actions", - method="GET", - params=params, + :param certificate: Certificate to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{certificate.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, certificate: Certificate | BoundCertificate, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Certificate. + """ + Returns all Actions for a Certificate. - :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param certificate: Certificate to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 3caf1be6..883c2dde 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( CreateFirewallResponse, @@ -97,22 +104,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Firewall. + """ + Returns a paginated list of Actions for a Firewall. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -124,17 +127,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Firewall. - - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Firewall. - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions( self, @@ -198,7 +198,10 @@ class FirewallsPageResult(NamedTuple): meta: Meta -class FirewallsClient(ResourceClientBase): +class FirewallsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/firewalls" actions: ResourceActionsClient @@ -214,59 +217,40 @@ def __init__(self, client: Client): def get_actions_list( self, firewall: Firewall | BoundFirewall, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Firewall. + """ + Returns a paginated list of Actions for a Firewall. - :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param firewall: Firewall to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - response = self._client.request( - url=f"{self._base_url}/{firewall.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{firewall.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, firewall: Firewall | BoundFirewall, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Firewall. - - :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Firewall. - :return: List[:class:`BoundAction `] + :param firewall: Firewall to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 771a094a..0ce29dd3 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateFloatingIPResponse, FloatingIP @@ -43,22 +50,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Floating IP. + """ + Returns a paginated list of Actions for a Floating IP. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -70,16 +73,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Floating IP. + """ + Returns all Actions for a Floating IP. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions(self, status=status, sort=sort) @@ -152,7 +153,10 @@ class FloatingIPsPageResult(NamedTuple): meta: Meta -class FloatingIPsClient(ResourceClientBase): +class FloatingIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/floating_ips" actions: ResourceActionsClient @@ -168,59 +172,40 @@ def __init__(self, client: Client): def get_actions_list( self, floating_ip: FloatingIP | BoundFloatingIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Floating IP. + """ + Returns a paginated list of Actions for a Floating IP. - :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param floating_ip: Floating IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - response = self._client.request( - url=f"{self._base_url}/{floating_ip.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{floating_ip.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, floating_ip: FloatingIP | BoundFloatingIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Floating IP. - - :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Floating IP. - :return: List[:class:`BoundAction `] + :param floating_ip: Floating IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/images/client.py b/hcloud/images/client.py index 822ff14a..9d9d6f1e 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -3,7 +3,14 @@ import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Image @@ -39,22 +46,18 @@ def __init__( def get_actions_list( self, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, - status: list[str] | None = None, ) -> ActionsPageResult: - """Returns a list of action objects for the image. + """ + Returns a paginated list of Actions for a Image. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -66,16 +69,14 @@ def get_actions_list( def get_actions( self, - sort: list[str] | None = None, - status: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for the image. + """ + Returns all Actions for a Image. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions( self, @@ -126,7 +127,10 @@ class ImagesPageResult(NamedTuple): meta: Meta -class ImagesClient(ResourceClientBase): +class ImagesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/images" actions: ResourceActionsClient @@ -142,64 +146,46 @@ def __init__(self, client: Client): def get_actions_list( self, image: Image | BoundImage, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, - status: list[str] | None = None, ) -> ActionsPageResult: - """Returns a list of action objects for an image. + """ + Returns a paginated list of Actions for a Image. - :param image: :class:`BoundImage ` or :class:`Image ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param image: Image to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if sort is not None: - params["sort"] = sort - if status is not None: - params["status"] = status - 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}/{image.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{image.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, image: Image | BoundImage, - sort: list[str] | None = None, - status: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for an image. + """ + Returns all Actions for a Image. - :param image: :class:`BoundImage ` or :class:`Image ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :return: List[:class:`BoundAction `] + :param image: Image to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, image, - sort=sort, status=status, + sort=sort, ) def get_by_id(self, id: int) -> BoundImage: diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index c0d373ce..e7305f22 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -5,7 +5,14 @@ from dateutil.parser import isoparse -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..certificates import BoundCertificate from ..core import BoundModelBase, Meta, ResourceClientBase from ..load_balancer_types import BoundLoadBalancerType @@ -212,22 +219,18 @@ def get_metrics( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Load Balancer. + """ + Returns a paginated list of Actions for a Load Balancer. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -239,16 +242,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Load Balancer. + """ + Returns all Actions for a Load Balancer. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions(self, status=status, sort=sort) @@ -388,7 +389,10 @@ class LoadBalancersPageResult(NamedTuple): meta: Meta -class LoadBalancersClient(ResourceClientBase): +class LoadBalancersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/load_balancers" actions: ResourceActionsClient @@ -621,59 +625,40 @@ def get_metrics( def get_actions_list( self, load_balancer: LoadBalancer | BoundLoadBalancer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Load Balancer. - - :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Load Balancer. - response = self._client.request( - url=f"{self._base_url}/{load_balancer.id}/actions", - method="GET", - params=params, + :param load_balancer: Load Balancer to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{load_balancer.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, load_balancer: LoadBalancer | BoundLoadBalancer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Load Balancer. + """ + Returns all Actions for a Load Balancer. - :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param load_balancer: Load Balancer to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 438d4a98..54d04cbd 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Network, NetworkRoute, NetworkSubnet @@ -77,22 +84,18 @@ def delete(self) -> bool: def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a network. + """ + Returns a paginated list of Actions for a Network. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -104,16 +107,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a network. + """ + Returns all Actions for a Network. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions(self, status=status, sort=sort) @@ -177,7 +178,10 @@ class NetworksPageResult(NamedTuple): meta: Meta -class NetworksClient(ResourceClientBase): +class NetworksClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/networks" actions: ResourceActionsClient @@ -364,59 +368,40 @@ def delete(self, network: Network | BoundNetwork) -> bool: def get_actions_list( self, network: Network | BoundNetwork, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a network. - - :param network: :class:`BoundNetwork ` or :class:`Network ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Network. - response = self._client.request( - url=f"{self._base_url}/{network.id}/actions", - method="GET", - params=params, + :param network: Network to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{network.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, network: Network | BoundNetwork, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a network. + """ + Returns all Actions for a Network. - :param network: :class:`BoundNetwork ` or :class:`Network ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param network: Network to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index cc26e55e..15acdbf6 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -3,7 +3,14 @@ import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePrimaryIPResponse, PrimaryIP @@ -40,21 +47,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ Returns a paginated list of Actions for a Primary IP. - See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - - :param primary_ip: Primary IP to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -66,17 +70,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ Returns all Actions for a Primary IP. - See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - - :param primary_ip: Primary IP to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions( self, @@ -160,7 +161,10 @@ class PrimaryIPsPageResult(NamedTuple): meta: Meta -class PrimaryIPsClient(ResourceClientBase): +class PrimaryIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/primary_ips" actions: ResourceActionsClient @@ -176,57 +180,40 @@ def __init__(self, client: Client): def get_actions_list( self, primary_ip: PrimaryIP | BoundPrimaryIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ Returns a paginated list of Actions for a Primary IP. - See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - - :param primary_ip: Primary IP to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param primary_ip: Primary IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - - response = self._client.request( - url=f"{self._base_url}/{primary_ip.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{primary_ip.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, primary_ip: PrimaryIP | BoundPrimaryIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ Returns all Actions for a Primary IP. - See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - - :param primary_ip: Primary IP to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. + :param primary_ip: Primary IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index e65e4e08..847aa88a 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -6,7 +6,14 @@ from dateutil.parser import isoparse -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from ..datacenters import BoundDatacenter from ..firewalls import BoundFirewall @@ -176,22 +183,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a server. + """ + Returns a paginated list of Actions for a Server. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -203,16 +206,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a server. + """ + Returns all Actions for a Server. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions(self, status=status, sort=sort) @@ -513,7 +514,10 @@ class ServersPageResult(NamedTuple): meta: Meta -class ServersClient(ResourceClientBase): +class ServersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/servers" actions: ResourceActionsClient @@ -719,59 +723,40 @@ def create( def get_actions_list( self, server: Server | BoundServer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a server. - - :param server: :class:`BoundServer ` or :class:`Server ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Server. - response = self._client.request( - url=f"{self._base_url}/{server.id}/actions", - method="GET", - params=params, + :param server: Server to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{server.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, server: Server | BoundServer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a server. + """ + Returns all Actions for a Server. - :param server: :class:`BoundServer ` or :class:`Server ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param server: Server to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index f81e3c81..299ce204 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation, Location from ..ssh_keys import BoundSSHKey, SSHKey @@ -67,20 +74,20 @@ def __init__( def get_actions_list( self, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ - Returns a paginated list of Actions for a Storage Box for a specific page. + Returns a paginated list of Actions for a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. Experimental: Storage Box support is experimental, breaking changes may occur within minor releases. @@ -96,8 +103,8 @@ def get_actions_list( def get_actions( self, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ Returns all Actions for a Storage Box. @@ -743,7 +750,10 @@ class StorageBoxSubaccountsPageResult(NamedTuple): meta: Meta -class StorageBoxesClient(ResourceClientBase): +class StorageBoxesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): """ A client for the Storage Boxes API. @@ -1012,58 +1022,46 @@ def get_actions_list( self, storage_box: StorageBox | BoundStorageBox, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ - Returns a paginated list of Actions for a Storage Box for a specific page. + Returns a paginated list of Actions for a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box - :param storage_box: Storage Box to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param storage_box: Storage Box to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. Experimental: Storage Box support is experimental, breaking changes may occur within minor releases. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - - response = self._client.request( - method="GET", - url=f"/storage_boxes/{storage_box.id}/actions", - params=params, - ) - return ActionsPageResult( - actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], - meta=Meta.parse_meta(response), + return self._get_actions_list( + f"{self._base_url}/{storage_box.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) def get_actions( self, storage_box: StorageBox | BoundStorageBox, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ Returns all Actions for a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box - :param storage_box: Storage Box to fetch the Actions from. + :param storage_box: Storage Box to get the Actions for. :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 2e36fd13..84900eb3 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateVolumeResponse, Volume @@ -40,22 +47,18 @@ def __init__( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a volume. + """ + Returns a paginated list of Actions for a Volume. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, status=status, sort=sort, page=page, per_page=per_page @@ -63,16 +66,14 @@ def get_actions_list( def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a volume. + """ + Returns all Actions for a Volume. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions(self, status=status, sort=sort) @@ -142,7 +143,10 @@ class VolumesPageResult(NamedTuple): meta: Meta -class VolumesClient(ResourceClientBase): +class VolumesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): _base_url = "/volumes" actions: ResourceActionsClient @@ -293,59 +297,40 @@ def create( def get_actions_list( self, volume: Volume | BoundVolume, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a volume. - - :param volume: :class:`BoundVolume ` or :class:`Volume ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Volume. - response = self._client.request( - url=f"{self._base_url}/{volume.id}/actions", - method="GET", - params=params, + :param volume: Volume to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{volume.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._parent.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, volume: Volume | BoundVolume, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a volume. + """ + Returns all Actions for a Volume. - :param volume: :class:`BoundVolume ` or :class:`Volume ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param volume: Volume to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index 1e5383b1..e6a3ac3c 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -2,7 +2,14 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( CreateZoneResponse, @@ -81,20 +88,20 @@ def export_zonefile(self) -> ExportZonefileResponse: def get_actions_list( self, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ - Returns all Actions for the Zone for a specific page. + Returns a paginated list of Actions for a Zone. See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( self, @@ -107,16 +114,16 @@ def get_actions_list( def get_actions( self, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ - Returns all Actions for the Zone. + Returns all Actions for a Zone. See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._client.get_actions( self, @@ -551,7 +558,10 @@ class ZoneRRSetsPageResult(NamedTuple): meta: Meta -class ZonesClient(ResourceClientBase): +class ZonesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): """ ZoneClient is a client for the Zone (DNS) API. @@ -774,57 +784,45 @@ def get_actions_list( self, zone: Zone | BoundZone, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: """ - Returns all Actions for a Zone for a specific page. + Returns a paginated list of Actions for a Zone. See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - :param zone: Zone to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. - :param page: Page number to return. - :param per_page: Maximum number of entries returned per page. + :param zone: Zone to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - - response = self._client.request( - method="GET", - url=f"{self._base_url}/{zone.id_or_name}/actions", - params=params, - ) - return ActionsPageResult( - actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], - meta=Meta.parse_meta(response), + return self._get_actions_list( + f"{self._base_url}/{zone.id_or_name}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) def get_actions( self, zone: Zone | BoundZone, *, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ Returns all Actions for a Zone. See https://docs.hetzner.cloud/reference/cloud#zones-list-zones - :param zone: Zone to fetch the Actions from. - :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. - :param sort: Sort resources by field and direction. + :param zone: Zone to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, From e433d37f3ec4b88af6399c8ffea90303a31eafd0 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 19 Dec 2025 16:29:02 +0100 Subject: [PATCH 153/213] chore: define `__all__` for all modules and missing exports (#616) --- hcloud/__init__.py | 24 +++++++++++++++--------- hcloud/actions/__init__.py | 16 ++++++++++------ hcloud/actions/client.py | 9 +++++++++ hcloud/actions/domain.py | 10 ++++++++++ hcloud/certificates/client.py | 7 +++++++ hcloud/certificates/domain.py | 8 ++++++++ hcloud/core/client.py | 6 ++++++ hcloud/core/domain.py | 7 +++++++ hcloud/datacenters/client.py | 6 ++++++ hcloud/datacenters/domain.py | 5 +++++ hcloud/deprecation/__init__.py | 4 +++- hcloud/deprecation/domain.py | 4 ++++ hcloud/exp/zone.py | 5 +++++ hcloud/firewalls/client.py | 7 +++++++ hcloud/firewalls/domain.py | 10 ++++++++++ hcloud/floating_ips/__init__.py | 3 ++- hcloud/floating_ips/client.py | 6 ++++++ hcloud/floating_ips/domain.py | 7 +++++++ hcloud/helpers/__init__.py | 4 +++- hcloud/helpers/labels.py | 4 ++++ hcloud/images/__init__.py | 3 ++- hcloud/images/client.py | 7 +++++++ hcloud/images/domain.py | 7 +++++++ hcloud/isos/client.py | 6 ++++++ hcloud/isos/domain.py | 4 ++++ hcloud/load_balancer_types/client.py | 6 ++++++ hcloud/load_balancer_types/domain.py | 4 ++++ hcloud/load_balancers/__init__.py | 4 ++++ hcloud/load_balancers/client.py | 7 +++++++ hcloud/load_balancers/domain.py | 23 +++++++++++++++++++++++ hcloud/locations/client.py | 6 ++++++ hcloud/locations/domain.py | 4 ++++ hcloud/metrics/domain.py | 6 ++++++ hcloud/networks/__init__.py | 2 ++ hcloud/networks/client.py | 7 +++++++ hcloud/networks/domain.py | 8 ++++++++ hcloud/placement_groups/client.py | 6 ++++++ hcloud/placement_groups/domain.py | 5 +++++ hcloud/primary_ips/__init__.py | 3 ++- hcloud/primary_ips/client.py | 7 +++++++ hcloud/primary_ips/domain.py | 6 ++++++ hcloud/rdns/domain.py | 4 ++++ hcloud/server_types/client.py | 6 ++++++ hcloud/server_types/domain.py | 5 +++++ hcloud/servers/__init__.py | 6 ++++++ hcloud/servers/client.py | 7 +++++++ hcloud/servers/domain.py | 19 +++++++++++++++++++ hcloud/ssh_keys/client.py | 6 ++++++ hcloud/ssh_keys/domain.py | 4 ++++ hcloud/storage_box_types/client.py | 6 ++++++ hcloud/storage_box_types/domain.py | 4 ++++ hcloud/storage_boxes/client.py | 10 ++++++++++ hcloud/storage_boxes/domain.py | 20 ++++++++++++++++++++ hcloud/volumes/__init__.py | 3 ++- hcloud/volumes/client.py | 7 +++++++ hcloud/volumes/domain.py | 6 ++++++ hcloud/zones/__init__.py | 20 ++++++++++++++++++++ hcloud/zones/client.py | 8 ++++++++ hcloud/zones/domain.py | 18 ++++++++++++++++++ 59 files changed, 421 insertions(+), 21 deletions(-) diff --git a/hcloud/__init__.py b/hcloud/__init__.py index 95709bb0..8a9fa148 100644 --- a/hcloud/__init__.py +++ b/hcloud/__init__.py @@ -1,12 +1,18 @@ from __future__ import annotations -from ._client import ( # noqa pylint: disable=C0414 - Client as Client, - constant_backoff_function as constant_backoff_function, - exponential_backoff_function as exponential_backoff_function, +from ._client import ( + Client, + constant_backoff_function, + exponential_backoff_function, ) -from ._exceptions import ( # noqa pylint: disable=C0414 - APIException as APIException, - HCloudException as HCloudException, -) -from ._version import __version__ # noqa +from ._exceptions import APIException, HCloudException +from ._version import __version__ + +__all__ = [ + "__version__", + "Client", + "constant_backoff_function", + "exponential_backoff_function", + "APIException", + "HCloudException", +] diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index 653ad9bd..ac677936 100644 --- a/hcloud/actions/__init__.py +++ b/hcloud/actions/__init__.py @@ -9,21 +9,25 @@ ) from .domain import ( Action, + ActionError, ActionException, ActionFailedException, + ActionResource, ActionStatus, ActionTimeoutException, ) __all__ = [ - "Action", - "ActionStatus", - "ActionException", - "ActionFailedException", - "ActionTimeoutException", "ActionsClient", - "ActionSort", "ActionsPageResult", "BoundAction", "ResourceActionsClient", + "ActionSort", + "ActionStatus", + "Action", + "ActionResource", + "ActionError", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", ] diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index 26924ec1..176a6b18 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -11,6 +11,15 @@ from .._client import Client +__all__ = [ + "ActionsClient", + "ActionsPageResult", + "BoundAction", + "ResourceActionsClient", + "ActionSort", +] + + class BoundAction(BoundModelBase[Action], Action): _client: ActionsClient diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index 99267599..59b7f442 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -8,6 +8,16 @@ if TYPE_CHECKING: from .client import BoundAction +__all__ = [ + "ActionStatus", + "Action", + "ActionResource", + "ActionError", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", +] + ActionStatus = Literal[ "running", "success", diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index cf89196d..46a6a538 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -22,6 +22,13 @@ from .._client import Client +__all__ = [ + "BoundCertificate", + "CertificatesPageResult", + "CertificatesClient", +] + + class BoundCertificate(BoundModelBase[Certificate], Certificate): _client: CertificatesClient diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py index 455ea4ad..677ab8f2 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -9,6 +9,14 @@ from .client import BoundCertificate +__all__ = [ + "Certificate", + "ManagedCertificateStatus", + "ManagedCertificateError", + "CreateManagedCertificateResponse", +] + + class Certificate(BaseDomain, DomainIdentityMixin): """Certificate Domain diff --git a/hcloud/core/client.py b/hcloud/core/client.py index 86bb1bb7..4ff7f240 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -10,6 +10,12 @@ from .._client import Client, ClientBase from .domain import Meta +__all__ = [ + "ResourceClientBase", + "ClientEntityBase", + "BoundModelBase", +] + T = TypeVar("T") diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index de75ffc5..6659df80 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -5,6 +5,13 @@ from dateutil.parser import isoparse +__all__ = [ + "BaseDomain", + "DomainIdentityMixin", + "Pagination", + "Meta", +] + class BaseDomain: __api_properties__: tuple[str, ...] diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 192abaf5..5d7fe964 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -7,6 +7,12 @@ from ..server_types import BoundServerType from .domain import Datacenter, DatacenterServerTypes +__all__ = [ + "BoundDatacenter", + "DatacentersPageResult", + "DatacentersClient", +] + class BoundDatacenter(BoundModelBase[Datacenter], Datacenter): _client: DatacentersClient diff --git a/hcloud/datacenters/domain.py b/hcloud/datacenters/domain.py index badd335d..4867915d 100644 --- a/hcloud/datacenters/domain.py +++ b/hcloud/datacenters/domain.py @@ -8,6 +8,11 @@ from ..locations import Location from ..server_types import BoundServerType +__all__ = [ + "Datacenter", + "DatacenterServerTypes", +] + class Datacenter(BaseDomain, DomainIdentityMixin): """Datacenter Domain diff --git a/hcloud/deprecation/__init__.py b/hcloud/deprecation/__init__.py index db30b0fb..4dd0e91f 100644 --- a/hcloud/deprecation/__init__.py +++ b/hcloud/deprecation/__init__.py @@ -2,4 +2,6 @@ from .domain import DeprecationInfo -__all__ = ["DeprecationInfo"] +__all__ = [ + "DeprecationInfo", +] diff --git a/hcloud/deprecation/domain.py b/hcloud/deprecation/domain.py index c34915b3..c78c43e6 100644 --- a/hcloud/deprecation/domain.py +++ b/hcloud/deprecation/domain.py @@ -2,6 +2,10 @@ from ..core import BaseDomain +__all__ = [ + "DeprecationInfo", +] + class DeprecationInfo(BaseDomain): """Describes if, when & how the resources was deprecated. If this field is set to ``None`` the resource is not diff --git a/hcloud/exp/zone.py b/hcloud/exp/zone.py index 08601286..c8dd3d01 100644 --- a/hcloud/exp/zone.py +++ b/hcloud/exp/zone.py @@ -5,6 +5,11 @@ from __future__ import annotations +__all__ = [ + "is_txt_record_quoted", + "format_txt_record", +] + def is_txt_record_quoted(value: str) -> bool: """ diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index 883c2dde..f314b68a 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -24,6 +24,13 @@ from .._client import Client +__all__ = [ + "BoundFirewall", + "FirewallsPageResult", + "FirewallsClient", +] + + class BoundFirewall(BoundModelBase[Firewall], Firewall): _client: FirewallsClient diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py index d644a051..76a9b596 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -10,6 +10,16 @@ from .client import BoundFirewall +__all__ = [ + "Firewall", + "FirewallRule", + "FirewallResource", + "FirewallResourceAppliedToResources", + "FirewallResourceLabelSelector", + "CreateFirewallResponse", +] + + class Firewall(BaseDomain, DomainIdentityMixin): """Firewall Domain diff --git a/hcloud/floating_ips/__init__.py b/hcloud/floating_ips/__init__.py index 8c45c774..5ae08fca 100644 --- a/hcloud/floating_ips/__init__.py +++ b/hcloud/floating_ips/__init__.py @@ -5,12 +5,13 @@ FloatingIPsClient, FloatingIPsPageResult, ) -from .domain import CreateFloatingIPResponse, FloatingIP +from .domain import CreateFloatingIPResponse, FloatingIP, FloatingIPProtection __all__ = [ "BoundFloatingIP", "CreateFloatingIPResponse", "FloatingIP", + "FloatingIPProtection", "FloatingIPsClient", "FloatingIPsPageResult", ] diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 0ce29dd3..88e7ea42 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -19,6 +19,12 @@ from ..locations import Location from ..servers import BoundServer, Server +__all__ = [ + "BoundFloatingIP", + "FloatingIPsPageResult", + "FloatingIPsClient", +] + class BoundFloatingIP(BoundModelBase[FloatingIP], FloatingIP): _client: FloatingIPsClient diff --git a/hcloud/floating_ips/domain.py b/hcloud/floating_ips/domain.py index 6d24e77f..3946da7b 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -12,6 +12,13 @@ from .client import BoundFloatingIP +__all__ = [ + "FloatingIP", + "FloatingIPProtection", + "CreateFloatingIPResponse", +] + + class FloatingIP(BaseDomain, DomainIdentityMixin): """Floating IP Domain diff --git a/hcloud/helpers/__init__.py b/hcloud/helpers/__init__.py index 044703bf..e8ec1dcb 100644 --- a/hcloud/helpers/__init__.py +++ b/hcloud/helpers/__init__.py @@ -2,4 +2,6 @@ from .labels import LabelValidator -__all__ = ["LabelValidator"] +__all__ = [ + "LabelValidator", +] diff --git a/hcloud/helpers/labels.py b/hcloud/helpers/labels.py index 36041578..78f43142 100644 --- a/hcloud/helpers/labels.py +++ b/hcloud/helpers/labels.py @@ -2,6 +2,10 @@ import re +__all__ = [ + "LabelValidator", +] + class LabelValidator: KEY_REGEX = re.compile( diff --git a/hcloud/images/__init__.py b/hcloud/images/__init__.py index 071a92bc..c299722c 100644 --- a/hcloud/images/__init__.py +++ b/hcloud/images/__init__.py @@ -1,12 +1,13 @@ from __future__ import annotations from .client import BoundImage, ImagesClient, ImagesPageResult -from .domain import CreateImageResponse, Image +from .domain import CreateImageResponse, Image, ImageProtection __all__ = [ "BoundImage", "CreateImageResponse", "Image", + "ImageProtection", "ImagesClient", "ImagesPageResult", ] diff --git a/hcloud/images/client.py b/hcloud/images/client.py index 9d9d6f1e..2566a96f 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -18,6 +18,13 @@ from .._client import Client +__all__ = [ + "BoundImage", + "ImagesPageResult", + "ImagesClient", +] + + class BoundImage(BoundModelBase[Image], Image): _client: ImagesClient diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index ee653628..915ace64 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -10,6 +10,13 @@ from .client import BoundImage +__all__ = [ + "Image", + "ImageProtection", + "CreateImageResponse", +] + + class Image(BaseDomain, DomainIdentityMixin): """Image Domain diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index 9c5ea5ac..b9c3a5a0 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -5,6 +5,12 @@ from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Iso +__all__ = [ + "BoundIso", + "IsosPageResult", + "IsosClient", +] + class BoundIso(BoundModelBase[Iso], Iso): _client: IsosClient diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py index c3ebd662..5fd50c6e 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -7,6 +7,10 @@ from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo +__all__ = [ + "Iso", +] + class Iso(BaseDomain, DomainIdentityMixin): """Iso Domain diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index b639a37d..87d44e2f 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -5,6 +5,12 @@ from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import LoadBalancerType +__all__ = [ + "BoundLoadBalancerType", + "LoadBalancerTypesPageResult", + "LoadBalancerTypesClient", +] + class BoundLoadBalancerType(BoundModelBase[LoadBalancerType], LoadBalancerType): _client: LoadBalancerTypesClient diff --git a/hcloud/load_balancer_types/domain.py b/hcloud/load_balancer_types/domain.py index 8492ea7b..1e594e94 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -4,6 +4,10 @@ from ..core import BaseDomain, DomainIdentityMixin +__all__ = [ + "LoadBalancerType", +] + class LoadBalancerType(BaseDomain, DomainIdentityMixin): """LoadBalancerType Domain diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index f84c7e20..9b07d026 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -15,12 +15,14 @@ LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, + LoadBalancerProtection, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetHealthStatus, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + MetricsType, PrivateNet, PublicNetwork, ) @@ -32,6 +34,7 @@ "IPv4Address", "IPv6Network", "LoadBalancer", + "LoadBalancerProtection", "LoadBalancerAlgorithm", "LoadBalancerHealtCheckHttp", "LoadBalancerHealthCheckHttp", @@ -46,4 +49,5 @@ "LoadBalancersPageResult", "PrivateNet", "PublicNetwork", + "MetricsType", ] diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index e7305f22..1986905b 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -47,6 +47,13 @@ from ..networks import Network +__all__ = [ + "BoundLoadBalancer", + "LoadBalancersPageResult", + "LoadBalancersClient", +] + + class BoundLoadBalancer(BoundModelBase[LoadBalancer], LoadBalancer): _client: LoadBalancersClient diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index a5b62b50..c02e1100 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -16,6 +16,29 @@ from .client import BoundLoadBalancer +__all__ = [ + "LoadBalancer", + "LoadBalancerProtection", + "LoadBalancerService", + "LoadBalancerServiceHttp", + "LoadBalancerHealthCheck", + "LoadBalancerHealthCheckHttp", + "LoadBalancerHealtCheckHttp", + "LoadBalancerTarget", + "LoadBalancerTargetHealthStatus", + "LoadBalancerTargetLabelSelector", + "LoadBalancerTargetIP", + "LoadBalancerAlgorithm", + "PublicNetwork", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "CreateLoadBalancerResponse", + "GetMetricsResponse", + "MetricsType", +] + + class LoadBalancer(BaseDomain, DomainIdentityMixin): """LoadBalancer Domain diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 8c7b5e5e..5a71c0f6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -5,6 +5,12 @@ from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Location +__all__ = [ + "BoundLocation", + "LocationsPageResult", + "LocationsClient", +] + class BoundLocation(BoundModelBase[Location], Location): _client: LocationsClient diff --git a/hcloud/locations/domain.py b/hcloud/locations/domain.py index dcdf7a95..b33b157b 100644 --- a/hcloud/locations/domain.py +++ b/hcloud/locations/domain.py @@ -2,6 +2,10 @@ from ..core import BaseDomain, DomainIdentityMixin +__all__ = [ + "Location", +] + class Location(BaseDomain, DomainIdentityMixin): """Location Domain diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py index 69091d27..3213e279 100644 --- a/hcloud/metrics/domain.py +++ b/hcloud/metrics/domain.py @@ -5,6 +5,12 @@ from ..core import BaseDomain +__all__ = [ + "TimeSeries", + "Metrics", +] + + TimeSeries = dict[str, dict[Literal["values"], list[tuple[float, str]]]] diff --git a/hcloud/networks/__init__.py b/hcloud/networks/__init__.py index fc86564b..f49b9934 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -4,6 +4,7 @@ from .domain import ( CreateNetworkResponse, Network, + NetworkProtection, NetworkRoute, NetworkSubnet, ) @@ -12,6 +13,7 @@ "BoundNetwork", "CreateNetworkResponse", "Network", + "NetworkProtection", "NetworkRoute", "NetworkSubnet", "NetworksClient", diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 54d04cbd..73b33e5d 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -17,6 +17,13 @@ from .._client import Client +__all__ = [ + "BoundNetwork", + "NetworksPageResult", + "NetworksClient", +] + + class BoundNetwork(BoundModelBase[Network], Network): _client: NetworksClient diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 9f14a579..80c3badc 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -10,6 +10,14 @@ from ..servers import BoundServer from .client import BoundNetwork +__all__ = [ + "Network", + "NetworkProtection", + "NetworkSubnet", + "NetworkRoute", + "CreateNetworkResponse", +] + class Network(BaseDomain, DomainIdentityMixin): """Network Domain diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index f59e7e6c..041118d8 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -6,6 +6,12 @@ from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePlacementGroupResponse, PlacementGroup +__all__ = [ + "BoundPlacementGroup", + "PlacementGroupsPageResult", + "PlacementGroupsClient", +] + class BoundPlacementGroup(BoundModelBase[PlacementGroup], PlacementGroup): _client: PlacementGroupsClient diff --git a/hcloud/placement_groups/domain.py b/hcloud/placement_groups/domain.py index 59958ace..1dc9b9dd 100644 --- a/hcloud/placement_groups/domain.py +++ b/hcloud/placement_groups/domain.py @@ -8,6 +8,11 @@ from ..actions import BoundAction from .client import BoundPlacementGroup +__all__ = [ + "PlacementGroup", + "CreatePlacementGroupResponse", +] + class PlacementGroup(BaseDomain, DomainIdentityMixin): """Placement Group Domain diff --git a/hcloud/primary_ips/__init__.py b/hcloud/primary_ips/__init__.py index 3dc5ed0a..99974046 100644 --- a/hcloud/primary_ips/__init__.py +++ b/hcloud/primary_ips/__init__.py @@ -1,12 +1,13 @@ from __future__ import annotations from .client import BoundPrimaryIP, PrimaryIPsClient, PrimaryIPsPageResult -from .domain import CreatePrimaryIPResponse, PrimaryIP +from .domain import CreatePrimaryIPResponse, PrimaryIP, PrimaryIPProtection __all__ = [ "BoundPrimaryIP", "CreatePrimaryIPResponse", "PrimaryIP", + "PrimaryIPProtection", "PrimaryIPsClient", "PrimaryIPsPageResult", ] diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 15acdbf6..6d6693dc 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -20,6 +20,13 @@ from ..locations import BoundLocation, Location +__all__ = [ + "BoundPrimaryIP", + "PrimaryIPsPageResult", + "PrimaryIPsClient", +] + + class BoundPrimaryIP(BoundModelBase[PrimaryIP], PrimaryIP): _client: PrimaryIPsClient diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index 58cd596f..96749be2 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -12,6 +12,12 @@ from ..rdns import DNSPtr from .client import BoundPrimaryIP +__all__ = [ + "PrimaryIP", + "PrimaryIPProtection", + "CreatePrimaryIPResponse", +] + class PrimaryIP(BaseDomain, DomainIdentityMixin): """Primary IP Domain diff --git a/hcloud/rdns/domain.py b/hcloud/rdns/domain.py index 0f8b0341..e0e5ac66 100644 --- a/hcloud/rdns/domain.py +++ b/hcloud/rdns/domain.py @@ -2,6 +2,10 @@ from typing import TypedDict +__all__ = [ + "DNSPtr", +] + class DNSPtr(TypedDict): ip: str diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 33f5ebda..98da70af 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -6,6 +6,12 @@ from ..locations import BoundLocation from .domain import ServerType, ServerTypeLocation +__all__ = [ + "BoundServerType", + "ServerTypesPageResult", + "ServerTypesClient", +] + class BoundServerType(BoundModelBase[ServerType], ServerType): _client: ServerTypesClient diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index 1c470636..279d928d 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -7,6 +7,11 @@ from ..deprecation import DeprecationInfo from ..locations import BoundLocation +__all__ = [ + "ServerType", + "ServerTypeLocation", +] + class ServerType(BaseDomain, DomainIdentityMixin): """ServerType Domain diff --git a/hcloud/servers/__init__.py b/hcloud/servers/__init__.py index 0b02a110..c71c63ef 100644 --- a/hcloud/servers/__init__.py +++ b/hcloud/servers/__init__.py @@ -7,13 +7,16 @@ GetMetricsResponse, IPv4Address, IPv6Network, + MetricsType, PrivateNet, PublicNetwork, PublicNetworkFirewall, + RebuildResponse, RequestConsoleResponse, ResetPasswordResponse, Server, ServerCreatePublicNetwork, + ServerProtection, ) __all__ = [ @@ -29,7 +32,10 @@ "RequestConsoleResponse", "ResetPasswordResponse", "Server", + "ServerProtection", "ServerCreatePublicNetwork", "ServersClient", "ServersPageResult", + "RebuildResponse", + "MetricsType", ] diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index 847aa88a..cd3c2b07 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -56,6 +56,13 @@ from .domain import ServerCreatePublicNetwork +__all__ = [ + "BoundServer", + "ServersPageResult", + "ServersClient", +] + + class BoundServer(BoundModelBase[Server], Server): _client: ServersClient diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index 1a052886..ec019e4e 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -23,6 +23,25 @@ from .client import BoundServer +__all__ = [ + "Server", + "ServerProtection", + "CreateServerResponse", + "ResetPasswordResponse", + "EnableRescueResponse", + "RequestConsoleResponse", + "RebuildResponse", + "PublicNetwork", + "PublicNetworkFirewall", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "ServerCreatePublicNetwork", + "GetMetricsResponse", + "MetricsType", +] + + class Server(BaseDomain, DomainIdentityMixin): """Server Domain diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index fe2be196..d88800a1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -5,6 +5,12 @@ from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import SSHKey +__all__ = [ + "BoundSSHKey", + "SSHKeysPageResult", + "SSHKeysClient", +] + class BoundSSHKey(BoundModelBase[SSHKey], SSHKey): _client: SSHKeysClient diff --git a/hcloud/ssh_keys/domain.py b/hcloud/ssh_keys/domain.py index 1d957ba1..c3fc7c60 100644 --- a/hcloud/ssh_keys/domain.py +++ b/hcloud/ssh_keys/domain.py @@ -2,6 +2,10 @@ from ..core import BaseDomain, DomainIdentityMixin +__all__ = [ + "SSHKey", +] + class SSHKey(BaseDomain, DomainIdentityMixin): """SSHKey Domain diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index 2f001e27..25c1e890 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -8,6 +8,12 @@ if TYPE_CHECKING: from .._client import Client +__all__ = [ + "BoundStorageBoxType", + "StorageBoxTypesPageResult", + "StorageBoxTypesClient", +] + class BoundStorageBoxType(BoundModelBase[StorageBoxType], StorageBoxType): _client: StorageBoxTypesClient diff --git a/hcloud/storage_box_types/domain.py b/hcloud/storage_box_types/domain.py index dc90a8d4..b393b153 100644 --- a/hcloud/storage_box_types/domain.py +++ b/hcloud/storage_box_types/domain.py @@ -5,6 +5,10 @@ from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo +__all__ = [ + "StorageBoxType", +] + class StorageBoxType(BaseDomain, DomainIdentityMixin): """ diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 299ce204..4d258b97 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -35,6 +35,16 @@ if TYPE_CHECKING: from .._client import Client +__all__ = [ + "BoundStorageBox", + "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", + "StorageBoxesPageResult", + "StorageBoxSnapshotsPageResult", + "StorageBoxSubaccountsPageResult", + "StorageBoxesClient", +] + class BoundStorageBox(BoundModelBase[StorageBox], StorageBox): _client: StorageBoxesClient diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 85470954..03eaab1f 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -14,6 +14,26 @@ BoundStorageBoxSubaccount, ) +__all__ = [ + "StorageBox", + "StorageBoxAccessSettings", + "StorageBoxStats", + "StorageBoxSnapshotPlan", + "CreateStorageBoxResponse", + "DeleteStorageBoxResponse", + "StorageBoxFoldersResponse", + "StorageBoxSnapshot", + "StorageBoxSnapshotStats", + "CreateStorageBoxSnapshotResponse", + "DeleteStorageBoxSnapshotResponse", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "CreateStorageBoxSubaccountResponse", + "DeleteStorageBoxSubaccountResponse", + "StorageBoxStatus", +] + + StorageBoxStatus = Literal[ "active", "initializing", diff --git a/hcloud/volumes/__init__.py b/hcloud/volumes/__init__.py index 046df102..17226236 100644 --- a/hcloud/volumes/__init__.py +++ b/hcloud/volumes/__init__.py @@ -1,12 +1,13 @@ from __future__ import annotations from .client import BoundVolume, VolumesClient, VolumesPageResult -from .domain import CreateVolumeResponse, Volume +from .domain import CreateVolumeResponse, Volume, VolumeProtection __all__ = [ "BoundVolume", "CreateVolumeResponse", "Volume", + "VolumeProtection", "VolumesClient", "VolumesPageResult", ] diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index 84900eb3..a4155cf2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -20,6 +20,13 @@ from ..servers import BoundServer, Server +__all__ = [ + "BoundVolume", + "VolumesPageResult", + "VolumesClient", +] + + class BoundVolume(BoundModelBase[Volume], Volume): _client: VolumesClient diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py index 1e2e4379..2ed33338 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -10,6 +10,12 @@ from ..servers import BoundServer, Server from .client import BoundVolume +__all__ = [ + "Volume", + "VolumeProtection", + "CreateVolumeResponse", +] + class Volume(BaseDomain, DomainIdentityMixin): """Volume Domain diff --git a/hcloud/zones/__init__.py b/hcloud/zones/__init__.py index 7871e100..4cbee7fb 100644 --- a/hcloud/zones/__init__.py +++ b/hcloud/zones/__init__.py @@ -3,16 +3,26 @@ from .client import ( BoundZone, BoundZoneRRSet, + ZoneRRSetsPageResult, ZonesClient, ZonesPageResult, ) from .domain import ( CreateZoneResponse, + CreateZoneRRSetResponse, + DeleteZoneResponse, + DeleteZoneRRSetResponse, + ExportZonefileResponse, Zone, ZoneAuthoritativeNameservers, + ZoneMode, ZonePrimaryNameserver, + ZoneProtection, ZoneRecord, + ZoneRegistrar, ZoneRRSet, + ZoneRRSetProtection, + ZoneStatus, ) __all__ = [ @@ -26,4 +36,14 @@ "ZoneRRSet", "ZonesClient", "ZonesPageResult", + "DeleteZoneRRSetResponse", + "ZoneRRSetProtection", + "DeleteZoneResponse", + "ZoneRegistrar", + "ZoneMode", + "ZoneRRSetsPageResult", + "ZoneProtection", + "ExportZonefileResponse", + "CreateZoneRRSetResponse", + "ZoneStatus", ] diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py index e6a3ac3c..2f680286 100644 --- a/hcloud/zones/client.py +++ b/hcloud/zones/client.py @@ -29,6 +29,14 @@ if TYPE_CHECKING: from .._client import Client +__all__ = [ + "BoundZone", + "BoundZoneRRSet", + "ZonesPageResult", + "ZoneRRSetsPageResult", + "ZonesClient", +] + class BoundZone(BoundModelBase[Zone], Zone): _client: ZonesClient diff --git a/hcloud/zones/domain.py b/hcloud/zones/domain.py index ad24035f..77aee489 100644 --- a/hcloud/zones/domain.py +++ b/hcloud/zones/domain.py @@ -9,6 +9,24 @@ from .client import BoundZone, BoundZoneRRSet +__all__ = [ + "ZoneMode", + "ZoneStatus", + "ZoneRegistrar", + "Zone", + "ZonePrimaryNameserver", + "ZoneAuthoritativeNameservers", + "ZoneProtection", + "CreateZoneResponse", + "DeleteZoneResponse", + "ExportZonefileResponse", + "ZoneRRSet", + "ZoneRRSetProtection", + "ZoneRecord", + "CreateZoneRRSetResponse", + "DeleteZoneRRSetResponse", +] + ZoneMode = Literal["primary", "secondary"] ZoneStatus = Literal["ok", "updating", "error"] ZoneRegistrar = Literal["hetzner", "other", "unknown"] From 72a76a014e245e4876593ca9bfa0aa1a7eb5c022 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:36:54 +0100 Subject: [PATCH 154/213] chore(main): release v2.13.0 (#610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - add per primary ip actions list operations (#608) - deprecate datacenter in `primary ips` and `servers` (#609) ---

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 | 7 +++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 325fd316..465adea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [v2.13.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.13.0) + +### Features + +- add per primary ip actions list operations (#608) +- deprecate datacenter in `primary ips` and `servers` (#609) + ## [v2.12.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.12.0) ### Storage Box API Experimental diff --git a/hcloud/_version.py b/hcloud/_version.py index 77282e5e..4e8b02a6 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.12.0" # x-releaser-pleaser-version +__version__ = "2.13.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index c07b12f0..e38a2c6b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.12.0", # x-releaser-pleaser-version + version="2.13.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 101b2d11fd33f5e0e2452427392a727c79033662 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 7 Jan 2026 13:44:20 +0100 Subject: [PATCH 155/213] feat: retry requests when the api returns a `timeout` error (#617) This error code explicitly document that the request should be retried. --- hcloud/_client.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hcloud/_client.py b/hcloud/_client.py index c42f4fc7..20f26c9d 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -129,6 +129,7 @@ class Client: - ``conflict`` - ``rate_limit_exceeded`` + - ``timeout`` Changes to the retry policy might occur between releases, and will not be considered breaking changes. @@ -419,6 +420,7 @@ def _retry_policy(self, exception: APIException) -> bool: return exception.code in ( "rate_limit_exceeded", "conflict", + "timeout", ) if isinstance(exception.code, int): From a538df9e16e015c33c404c3bc59403d26b2732c6 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:36:13 +0100 Subject: [PATCH 156/213] chore(main): release v2.14.0 (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - retry requests when the api returns a `timeout` error (#617) ---

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 465adea1..1f424554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.14.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.14.0) + +### Features + +- retry requests when the api returns a `timeout` error (#617) + ## [v2.13.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.13.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 4e8b02a6..79280c74 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.13.0" # x-releaser-pleaser-version +__version__ = "2.14.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index e38a2c6b..cc9220d5 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.13.0", # x-releaser-pleaser-version + version="2.14.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From c4a0122bc21e6e91d1950614b03a0e98ea38de5f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:58:54 +0100 Subject: [PATCH 157/213] chore(deps): update dependency myst-parser to v5 (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [myst-parser](https://redirect.github.com/executablebooks/MyST-Parser) | `>=4,<4.1` β†’ `>=5,<5.1` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/myst-parser/5.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/myst-parser/4.0.1/5.0.0?slim=true) | --- ### Release Notes
executablebooks/MyST-Parser (myst-parser) ### [`v5.0.0`](https://redirect.github.com/executablebooks/MyST-Parser/blob/HEAD/CHANGELOG.md#500---2026-01-15) [Compare Source](https://redirect.github.com/executablebooks/MyST-Parser/compare/v4.0.1...v5.0.0) This release significantly bumps the supported versions of core dependencies: ##### ‼️ Breaking Changes This release updates the minimum supported versions: - **Python**: `>=3.11` (dropped Python 3.10, tests up to 3.14) - **Sphinx**: `>=8,<10` (dropped Sphinx 7, added Sphinx 9) - **Docutils**: `>=0.20,<0.23` (dropped docutils 0.19, added docutils 0.22) - **markdown-it-py**: `~=4.0` (upgraded from v3) ##### ⬆️ Dependency Upgrades - ⬆️ Upgrade to markdown-it-py v4 by in - ⬆️ Drop Python 3.10 and Sphinx 7 by in - ⬆️ Drop docutils 0.19 by in - ⬆️ Add support for Python 3.14 by in - ⬆️ Support Sphinx v9 by in - ⬆️ Allow docutils 0.22 by in ##### πŸ‘Œ Improvements - πŸ‘Œ Improve generation of meta nodes by in ##### πŸ“š Documentation - πŸ“š Fix typo in tables.md by in - πŸ“š Fix minor typo in `cross-referencing.md` by in ##### πŸ”§ Internal / Maintenance - πŸ”§ Update pre-commit by in - πŸ”§ Add `AGENTS.md` by in **Full Changelog**: [v4.0.1...v5.0.0](https://redirect.github.com/executablebooks/MyST-Parser/compare/v4.0.1...v5.0.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index cc9220d5..40bf1bf8 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ "docs": [ "sphinx>=8,<8.3", "sphinx-rtd-theme>=3,<3.1", - "myst-parser>=4,<4.1", + "myst-parser>=5,<5.1", "watchdog>=6,<6.1", ], "test": [ From d644bc98e66fc079e85302f48623df60325ae219 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:18:22 +0100 Subject: [PATCH 158/213] chore(deps): update dependency sphinx-rtd-theme to >=3,<3.2 (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [sphinx-rtd-theme](https://redirect.github.com/readthedocs/sphinx_rtd_theme) | `>=3,<3.1` β†’ `>=3,<3.2` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/sphinx-rtd-theme/3.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/sphinx-rtd-theme/3.0.2/3.1.0?slim=true) | --- ### Release Notes
readthedocs/sphinx_rtd_theme (sphinx-rtd-theme) ### [`v3.1.0`](https://redirect.github.com/readthedocs/sphinx_rtd_theme/compare/3.0.2...3.1.0) [Compare Source](https://redirect.github.com/readthedocs/sphinx_rtd_theme/compare/3.0.2...3.1.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 40bf1bf8..8cf983d9 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,7 @@ extras_require={ "docs": [ "sphinx>=8,<8.3", - "sphinx-rtd-theme>=3,<3.1", + "sphinx-rtd-theme>=3,<3.2", "myst-parser>=5,<5.1", "watchdog>=6,<6.1", ], From 0d7b27eb87caf75b0c3344c655b3c2b9441a3891 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:21:28 +0100 Subject: [PATCH 159/213] chore(deps): update dependency sphinx to v9 (#601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [sphinx](https://redirect.github.com/sphinx-doc/sphinx) ([changelog](https://www.sphinx-doc.org/en/master/changes.html)) | `>=8,<8.3` β†’ `>=9,<9.2` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/sphinx/9.1.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/sphinx/8.2.3/9.1.0?slim=true) | --- ### Release Notes
sphinx-doc/sphinx (sphinx) ### [`v9.1.0`](https://redirect.github.com/sphinx-doc/sphinx/blob/HEAD/CHANGES.rst#Release-910-released-Dec-31-2025) [Compare Source](https://redirect.github.com/sphinx-doc/sphinx/compare/v9.0.4...v9.1.0) \===================================== ## Dependencies - [#​14153](https://redirect.github.com/sphinx-doc/sphinx/issues/14153): Drop Python 3.11 support. - [#​12555](https://redirect.github.com/sphinx-doc/sphinx/issues/12555): Drop Docutils 0.20 support. Patch by Adam Turner ## Features added - Add :meth:`~sphinx.application.Sphinx.add_static_dir` for copying static assets from extensions to the build output. Patch by Jared Dillard ## Bugs fixed - [#​14189](https://redirect.github.com/sphinx-doc/sphinx/issues/14189): autodoc: Fix duplicate `:no-index-entry:` for modules. Patch by Adam Turner - [#​13713](https://redirect.github.com/sphinx-doc/sphinx/issues/13713): Fix compatibility with MyST-Parser. Patch by Adam Turner - Fix tests for Python 3.15. Patch by Adam Turner - [#​14089](https://redirect.github.com/sphinx-doc/sphinx/issues/14089): autodoc: Fix default option parsing. Patch by Adam Turner - Remove incorrect static typing assertions. Patch by Adam Turner - [#​14050](https://redirect.github.com/sphinx-doc/sphinx/issues/14050): LaTeXTranslator fails to build documents using the "acronym" standard role. Patch by GΓΌnter Milde - LaTeX: Fix rendering for grid filled merged vertical cell. Patch by Tim Nordell - [#​14228](https://redirect.github.com/sphinx-doc/sphinx/issues/14228): LaTeX: Fix overrun footer for cases of merged vertical table cells. Patch by Tim Nordell - [#​14207](https://redirect.github.com/sphinx-doc/sphinx/issues/14207): Fix creating `HTMLThemeFactory` objects in third-party extensions. Patch by Adam Turner - [#​3099](https://redirect.github.com/sphinx-doc/sphinx/issues/3099): LaTeX: PDF build crashes if a code-block contains more than circa 1350 codelines (about 27 a4-sized pages at default pointsize). Patch by Jean-FranΓ§ois B. - [#​14064](https://redirect.github.com/sphinx-doc/sphinx/issues/14064): LaTeX: TABs ending up in sphinxVerbatim fail to obey tab stops. Patch by Jean-FranΓ§ois B. - [#​14089](https://redirect.github.com/sphinx-doc/sphinx/issues/14089): autodoc: Improve support for non-weakreferencable objects. Patch by Adam Turner - LaTeX: Fix accidental removal at `3.5.0` ([#​8854](https://redirect.github.com/sphinx-doc/sphinx/issues/8854)) of the documentation of `literalblockcappos` key of :ref:`'sphinxsetup' `. Patch by Jean-FranΓ§ois B. ### [`v9.0.4`](https://redirect.github.com/sphinx-doc/sphinx/releases/tag/v9.0.4): Sphinx 9.0.4 [Compare Source](https://redirect.github.com/sphinx-doc/sphinx/compare/v9.0.3...v9.0.4) Changelog: ## Bugs fixed - [#​14143](https://redirect.github.com/sphinx-doc/sphinx/issues/14143): Fix spurious build warnings when translators reorder references in strings, or use translated display text in references. Patch by Matt Wang. ### [`v9.0.3`](https://redirect.github.com/sphinx-doc/sphinx/releases/tag/v9.0.3): Sphinx 9.0.3 [Compare Source](https://redirect.github.com/sphinx-doc/sphinx/compare/v9.0.2...v9.0.3) Changelog: ## Bugs fixed - [#​14142](https://redirect.github.com/sphinx-doc/sphinx/issues/14142): autodoc: Restore some missing exports in `sphinx.ext.autodoc`. Patch by Adam Turner. ### [`v9.0.2`](https://redirect.github.com/sphinx-doc/sphinx/releases/tag/v9.0.2): Sphinx 9.0.2 [Compare Source](https://redirect.github.com/sphinx-doc/sphinx/compare/v9.0.1...v9.0.2) Changelog: ## Bugs fixed - [#​14142](https://redirect.github.com/sphinx-doc/sphinx/issues/14142): autodoc: Restore `sphinx.ext.autodoc.mock`. Patch by Adam Turner. ### [`v9.0.1`](https://redirect.github.com/sphinx-doc/sphinx/releases/tag/v9.0.1): Sphinx 9.0.1 [Compare Source](https://redirect.github.com/sphinx-doc/sphinx/compare/v9.0.0...v9.0.1) Changelog: ## Bugs fixed - [#​13942](https://redirect.github.com/sphinx-doc/sphinx/issues/13942): autodoc: Restore the mapping interface for options objects. Patch by Adam Turner. - [#​13942](https://redirect.github.com/sphinx-doc/sphinx/issues/13942): autodoc: Deprecate the mapping interface for options objects. Patch by Adam Turner. - [#​13387](https://redirect.github.com/sphinx-doc/sphinx/issues/13387): Update translations. ### [`v9.0.0`](https://redirect.github.com/sphinx-doc/sphinx/releases/tag/v9.0.0): Sphinx 9.0.0 Changelog: ## Dependencies - [#​13786](https://redirect.github.com/sphinx-doc/sphinx/issues/13786): Support [Docutils 0.22](https://docutils.sourceforge.io/RELEASE-NOTES.html#release-0-22-2026-07-29). Patch by Adam Turner. ## Incompatible changes - [#​13639](https://redirect.github.com/sphinx-doc/sphinx/issues/13639): `SphinxComponentRegistry.create_source_parser` no longer has an *app* parameter, instead taking *config* and *env*. Patch by Adam Turner. - [#​13679](https://redirect.github.com/sphinx-doc/sphinx/issues/13679): Non-decodable characters in source files now raise an error. Such bytes have been replaced with '?' along with logging a warning since Sphinx 2.0. Patch by Adam Turner. - [#​13751](https://redirect.github.com/sphinx-doc/sphinx/issues/13751), [#​14089](https://redirect.github.com/sphinx-doc/sphinx/issues/14089): `sphinx.ext.autodoc` has been substantially rewritten, and there may be some incompatible changes in edge cases, especially when extensions interact with autodoc internals. The `autodoc_use_legacy_class_based` option has been added to use the legacy (pre-9.0) implementation of autodoc. Patches by Adam Turner. - [#​13355](https://redirect.github.com/sphinx-doc/sphinx/issues/13355): Don't include escaped title content in the search index. Patch by Will Lachance. ## Deprecated - 13627: Deprecate remaining public `app` attributes, including `builder.app`, `env.app`, `events.app`, and `SphinxTransform.app`. Patch by Adam Turner. - [#​13637](https://redirect.github.com/sphinx-doc/sphinx/issues/13637): Deprecate the `set_application` method of `Parser` objects. Patch by Adam Turner. - [#​13644](https://redirect.github.com/sphinx-doc/sphinx/issues/13644): Deprecate the `Parser.config` and `env` attributes. Patch by Adam Turner. - [#​13665](https://redirect.github.com/sphinx-doc/sphinx/issues/13665): Deprecate support for non-UTF 8 source encodings, scheduled for removal in Sphinx 10. Patch by Adam Turner. - [#​13682](https://redirect.github.com/sphinx-doc/sphinx/issues/13682): Deprecate `sphinx.io`. Sphinx no longer uses the `sphinx.io` classes, having replaced them with standard Python I/O. The entire `sphinx.io` module will be removed in Sphinx 10. Patch by Adam Turner. - [#​13631](https://redirect.github.com/sphinx-doc/sphinx/issues/13631): `sphinx.environment.adapters.toctree.global_toctree_for_doc` and `sphinx.environment.BuildEnvironment.get_and_resolve_doctree` will require a *tags* keyword argument from Sphinx 11. It may optionally be passed from Sphinx 9 onwards. Patch by Adam Turner. ## Features added - [#​13332](https://redirect.github.com/sphinx-doc/sphinx/issues/13332): Add `doctest_fail_fast` option to exit after the first failed test. Patch by Till Hoffmann. - [#​13439](https://redirect.github.com/sphinx-doc/sphinx/issues/13439): linkcheck: Permit warning on every redirect with `linkcheck_allowed_redirects = {}`. Patch by Adam Turner and James Addison. - [#​13497](https://redirect.github.com/sphinx-doc/sphinx/issues/13497): Support C domain objects in the table of contents. - [#​13500](https://redirect.github.com/sphinx-doc/sphinx/issues/13500): LaTeX: add support for `fontawesome6` package. Patch by Jean-FranΓ§ois B. - [#​13509](https://redirect.github.com/sphinx-doc/sphinx/issues/13509): autodoc: Detect `typing_extensions.overload` and `final` decorators. Patch by Spencer Brown. - [#​13535](https://redirect.github.com/sphinx-doc/sphinx/issues/13535): html search: Update to the latest version of Snowball (v3.0.1). Patch by Adam Turner. - [#​13647](https://redirect.github.com/sphinx-doc/sphinx/issues/13647): LaTeX: allow more cases of table nesting. Patch by Jean-FranΓ§ois B. - [#​13657](https://redirect.github.com/sphinx-doc/sphinx/issues/13657): LaTeX: support CSS3 length units. Patch by Jean-FranΓ§ois B. - [#​13684](https://redirect.github.com/sphinx-doc/sphinx/issues/13684): intersphinx: Add a file-based cache for remote inventories. The location of the cache directory must not be relied upon externally, as it may change without notice or warning in future releases. Patch by Adam Turner. - [#​13805](https://redirect.github.com/sphinx-doc/sphinx/issues/13805): LaTeX: add support for `fontawesome7` package. Patch by Jean-FranΓ§ois B. - [#​13508](https://redirect.github.com/sphinx-doc/sphinx/issues/13508): autodoc: Initial support for PEP 695 type aliases. Patch by Martin MatouΕ‘, Jeremy Maitin-Shepard, and Adam Turner. - [#​14023](https://redirect.github.com/sphinx-doc/sphinx/issues/14023): Add the new `mathjax_config_path` option to load MathJax configuration from a file. Patch by Randolf Scholz and Adam Turner. - [#​14046](https://redirect.github.com/sphinx-doc/sphinx/issues/14046): linkcheck: Add the `linkcheck_case_insensitive_urls` option to allow case-insensitive URL comparison for specific URL patterns. This is useful for links to websites that normalise URL casing (e.g. GitHub) or case-insensitive servers. Patch by Fazeel Usmani and James Addison. - [#​14075](https://redirect.github.com/sphinx-doc/sphinx/issues/14075): autosummary: Provide more context in import exception stack traces. Patch by Philipp A. - [#​13468](https://redirect.github.com/sphinx-doc/sphinx/issues/13468): Add config options to `sphinx.ext.duration`. Patch by Erik Bedard and Adam Turner. - [#​14022](https://redirect.github.com/sphinx-doc/sphinx/issues/14022): Use MathJax v4 by default in the `sphinx.ext.mathjax` extension, from v3 previously. To keep using an older version, set the `mathjax_path` option. Also add the new `mathjax4_config` option to configure MathJax v4. Note that MathJax v3 is mostly compatible with MathJax v4, so existing `mathjax3_config` settings should not need to change. Patch by Matthias Geier. - [#​14029](https://redirect.github.com/sphinx-doc/sphinx/issues/14029): intersphinx: Fix error in format string interpolation. Patch by Matthieu de Cibeins. ## Bugs fixed - [#​13926](https://redirect.github.com/sphinx-doc/sphinx/issues/13926): multiple py:type directives for the same canonical type no longer result in spurious duplicate object description warnings. Patch by Jeremy Maitin-Shepard. - [#​1327](https://redirect.github.com/sphinx-doc/sphinx/issues/1327): LaTeX: tables using longtable raise error if `tabularcolumns` specifies automatic widths (`L`, `R`, `C`, or `J`). Patch by Jean-FranΓ§ois B. - [#​3447](https://redirect.github.com/sphinx-doc/sphinx/issues/3447): LaTeX: when assigning longtable class to table for PDF, it may render "horizontally" and overflow in right margin. Patch by Jean-FranΓ§ois B. - [#​8828](https://redirect.github.com/sphinx-doc/sphinx/issues/8828): LaTeX: adding a footnote to a longtable cell causes table to occupy full width. Patch by Jean-FranΓ§ois B. - [#​11498](https://redirect.github.com/sphinx-doc/sphinx/issues/11498): LaTeX: Table in cell fails to build if it has many rows. Patch by Jean-FranΓ§ois B. - [#​11515](https://redirect.github.com/sphinx-doc/sphinx/issues/11515): LaTeX: longtable does not allow nested table. Patch by Jean-FranΓ§ois B. - [#​11973](https://redirect.github.com/sphinx-doc/sphinx/issues/11973): LaTeX: links in table captions do not work in PDF. Patch by Jean-FranΓ§ois B. - [#​12821](https://redirect.github.com/sphinx-doc/sphinx/issues/12821): LaTeX: URLs/links in section titles should render in PDF. Patch by Jean-FranΓ§ois B. - [#​13369](https://redirect.github.com/sphinx-doc/sphinx/issues/13369): Correctly parse and cross-reference unpacked type annotations. Patch by Alicia Garcia-Raboso. - [#​13528](https://redirect.github.com/sphinx-doc/sphinx/issues/13528): Add tilde `~` prefix support for `py:deco`. Patch by Shengyu Zhang and Adam Turner. - [#​13597](https://redirect.github.com/sphinx-doc/sphinx/issues/13597): LaTeX: table nested in a merged cell leads to invalid LaTeX mark-up and PDF cannot be built. Patch by Jean-FranΓ§ois B. - [#​13619](https://redirect.github.com/sphinx-doc/sphinx/issues/13619): LaTeX: possible duplicated footnotes in PDF from object signatures (typically if `latex_show_urls` `= 'footnote'`). Patch by Jean-FranΓ§ois B. - [#​13635](https://redirect.github.com/sphinx-doc/sphinx/issues/13635): LaTeX: if a cell contains a table, row coloring is turned off for the next table cells. Patch by Jean-FranΓ§ois B. - [#​13685](https://redirect.github.com/sphinx-doc/sphinx/issues/13685): gettext: Correctly ignore trailing backslashes. Patch by BΓ©nΓ©dikt Tran. - [#​13712](https://redirect.github.com/sphinx-doc/sphinx/issues/13712): intersphinx: Don't add "v" prefix to non-numeric versions. Patch by Szymon Karpinski. - [#​13688](https://redirect.github.com/sphinx-doc/sphinx/issues/13688): HTML builder: Replace `` with `` for attribute type annotations to improve [semantic HTML structure](https://html.spec.whatwg.org/multipage/text-level-semantics.html). Patch by Mark Ostroth. - [#​13812](https://redirect.github.com/sphinx-doc/sphinx/issues/13812) (discussion): LaTeX: long `confval` value does not wrap at spaces in PDF. Patch by Jean-FranΓ§ois B. - [#​10785](https://redirect.github.com/sphinx-doc/sphinx/issues/10785): Autodoc: Allow type aliases defined in the project to be properly cross-referenced when used as type annotations. This makes it possible for objects documented as `:py:data:` to be hyperlinked in function signatures. - [#​13858](https://redirect.github.com/sphinx-doc/sphinx/issues/13858): doctest: doctest blocks are now correctly added to a group defined by the configuration variable `doctest_test_doctest_blocks`. - [#​13885](https://redirect.github.com/sphinx-doc/sphinx/issues/13885): Coverage builder: Fix TypeError when warning about missing modules. Patch by Damien Ayers. - [#​13929](https://redirect.github.com/sphinx-doc/sphinx/issues/13929): Duplicate equation label warnings now have a new warning sub-type, `ref.equation`. Patch by Jared Dillard. - [#​13935](https://redirect.github.com/sphinx-doc/sphinx/issues/13935): autoclass: parent class members no longer considered directly defined in certain cases, depending on autodoc processing order. Patch by Jeremy Maitin-Shepard. - [#​13939](https://redirect.github.com/sphinx-doc/sphinx/issues/13939): LaTeX: page break can separate admonition title from contents. Patch by Jean-FranΓ§ois B. - [#​14004](https://redirect.github.com/sphinx-doc/sphinx/issues/14004): Fix `autodoc_type_aliases` when they appear in PEP 604 union syntax (`Alias | Type`). Patch by Tamika Nomara. - [#​14059](https://redirect.github.com/sphinx-doc/sphinx/issues/14059): LaTeX: Footnotes cause pdflatex error with French language (since late June 2025 upstream change to LaTeX `babel-french`). Patch by Jean-FranΓ§ois B. - [#​13916](https://redirect.github.com/sphinx-doc/sphinx/issues/13916): HTML Search: do not clear text fragments from the URL on page load. Patch by Harmen Stoppels. - [#​13944](https://redirect.github.com/sphinx-doc/sphinx/issues/13944): autodoc: show traceback during import in human readable representation. Patch by Florian Best. - [#​14006](https://redirect.github.com/sphinx-doc/sphinx/issues/14006): Support images with data URIs that aren't base64-encoded. Patch by Shengyu Zhang and Adam Turner. - [#​12797](https://redirect.github.com/sphinx-doc/sphinx/issues/12797): Fix `Some type variables (...) are not listed in Generic[...]` TypeError when inheriting from both Generic and autodoc mocked class. Patch by Ikor Jefocur and Daniel Sperber. - [#​13945](https://redirect.github.com/sphinx-doc/sphinx/issues/13945): autodoc: Fix handling of undefined names in annotations by using the `FORWARDREF` `annotationlib` format. Patch by Rui Pinheiro and Adam Turner. - [#​14067](https://redirect.github.com/sphinx-doc/sphinx/issues/14067): EPUB: unify path separators in manifest items to forward slashes; resolve duplicates in the manifest on Windows. Patch by Akihiro Takizawa. - [#​13741](https://redirect.github.com/sphinx-doc/sphinx/issues/13741): text builder: fix an infinite loop when processing CSV tables. Patch by BΓ©nΓ©dikt Tran. - [#​13217](https://redirect.github.com/sphinx-doc/sphinx/issues/13217): Remove extra parentheses from `js:function` arguments and errors. Patch by Shengyu Zhang.
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8cf983d9..4bd7d10c 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,7 @@ ], extras_require={ "docs": [ - "sphinx>=8,<8.3", + "sphinx>=9,<9.2", "sphinx-rtd-theme>=3,<3.2", "myst-parser>=5,<5.1", "watchdog>=6,<6.1", From 8bff7435ba3aad5b2358d567ddd6e29be80f0c0f Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Fri, 16 Jan 2026 11:14:32 +0100 Subject: [PATCH 160/213] feat: add name to Storage Box Subaccount (#621) Related to https://docs.hetzner.cloud/changelog#2026-01-15-storage-box-subaccount-name --- hcloud/storage_boxes/client.py | 65 +++++++++++++++++++++++++ hcloud/storage_boxes/domain.py | 3 ++ tests/unit/storage_boxes/test_client.py | 23 +++++++++ 3 files changed, 91 insertions(+) diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index 4d258b97..e0c76798 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -452,6 +452,22 @@ def get_subaccount_by_id( """ return self._client.get_subaccount_by_id(self, id=id) + def get_subaccount_by_name( + self, + name: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param name: Name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._client.get_subaccount_by_name(self, name=name) + def get_subaccount_by_username( self, username: str, @@ -471,6 +487,7 @@ def get_subaccount_by_username( def get_subaccount_list( self, *, + name: str | None = None, username: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -480,6 +497,7 @@ def get_subaccount_list( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. @@ -489,6 +507,7 @@ def get_subaccount_list( """ return self._client.get_subaccount_list( self, + name=name, username=username, label_selector=label_selector, sort=sort, @@ -497,6 +516,7 @@ def get_subaccount_list( def get_subaccount_all( self, *, + name: str | None = None, username: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -506,6 +526,7 @@ def get_subaccount_all( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. @@ -515,6 +536,7 @@ def get_subaccount_all( """ return self._client.get_subaccount_all( self, + name=name, username=username, label_selector=label_selector, sort=sort, @@ -523,6 +545,7 @@ def get_subaccount_all( def create_subaccount( self, *, + name: str | None = None, home_directory: str, password: str, access_settings: StorageBoxSubaccountAccessSettings | None = None, @@ -535,6 +558,7 @@ def create_subaccount( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount :param storage_box: Storage Box to create a Subaccount for. + :param name: Name of the Subaccount. :param home_directory: Home directory of the Subaccount. :param password: Password of the Subaccount. :param access_settings: Access settings of the Subaccount. @@ -546,6 +570,7 @@ def create_subaccount( """ return self._client.create_subaccount( self, + name=name, home_directory=home_directory, password=password, access_settings=access_settings, @@ -658,6 +683,7 @@ def _get_self(self) -> BoundStorageBoxSubaccount: def update( self, *, + name: str | None = None, description: str | None = None, labels: dict[str, str] | None = None, ) -> BoundStorageBoxSubaccount: @@ -666,6 +692,7 @@ def update( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + :param name: Name of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. @@ -674,6 +701,7 @@ def update( """ return self._client.update_subaccount( self, + name=name, description=description, labels=labels, ) @@ -1510,6 +1538,28 @@ def get_subaccount_by_id( ) return BoundStorageBoxSubaccount(self, response["subaccount"]) + def get_subaccount_by_name( + self, + storage_box: StorageBox | BoundStorageBox, + name: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param name: Name of the Subaccount. + + Experimental: + Storage Box support is experimental, breaking changes may occur within minor releases. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + name=name, + ) + def get_subaccount_by_username( self, storage_box: StorageBox | BoundStorageBox, @@ -1536,6 +1586,7 @@ def get_subaccount_list( self, storage_box: StorageBox | BoundStorageBox, *, + name: str | None = None, username: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -1546,6 +1597,7 @@ def get_subaccount_list( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts :param storage_box: Storage Box to get the Subaccount from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. @@ -1554,6 +1606,8 @@ def get_subaccount_list( Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} + if name is not None: + params["name"] = name if username is not None: params["username"] = username if label_selector is not None: @@ -1578,6 +1632,7 @@ def get_subaccount_all( self, storage_box: StorageBox | BoundStorageBox, *, + name: str | None = None, username: str | None = None, label_selector: str | None = None, sort: list[str] | None = None, @@ -1588,6 +1643,7 @@ def get_subaccount_all( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts :param storage_box: Storage Box to get the Subaccount from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. @@ -1598,6 +1654,7 @@ def get_subaccount_all( # The endpoint does not have pagination, forward to the list method. result, _ = self.get_subaccount_list( storage_box, + name=name, username=username, label_selector=label_selector, sort=sort, @@ -1608,6 +1665,7 @@ def create_subaccount( self, storage_box: StorageBox | BoundStorageBox, *, + name: str | None = None, home_directory: str, password: str, access_settings: StorageBoxSubaccountAccessSettings | None = None, @@ -1620,6 +1678,7 @@ def create_subaccount( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount :param storage_box: Storage Box to create a Subaccount for. + :param name: Name of the Subaccount. :param home_directory: Home directory of the Subaccount. :param password: Password of the Subaccount. :param access_settings: Access settings of the Subaccount. @@ -1633,6 +1692,8 @@ def create_subaccount( "home_directory": home_directory, "password": password, } + if name is not None: + data["name"] = name if access_settings is not None: data["access_settings"] = access_settings.to_payload() if description is not None: @@ -1659,6 +1720,7 @@ def update_subaccount( self, subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, *, + name: str | None = None, description: str | None = None, labels: dict[str, str] | None = None, ) -> BoundStorageBoxSubaccount: @@ -1668,6 +1730,7 @@ def update_subaccount( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount :param subaccount: Storage Box Subaccount to update. + :param name: Name of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. @@ -1678,6 +1741,8 @@ def update_subaccount( raise ValueError("subaccount storage_box property is none") data: dict[str, Any] = {} + if name is not None: + data["name"] = name if description is not None: data["description"] = description if labels is not None: diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py index 03eaab1f..8d2237a2 100644 --- a/hcloud/storage_boxes/domain.py +++ b/hcloud/storage_boxes/domain.py @@ -373,6 +373,7 @@ class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): __api_properties__ = ( "id", + "name", "username", "description", "server", @@ -387,6 +388,7 @@ class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): def __init__( self, id: int | None = None, + name: str | None = None, username: str | None = None, description: str | None = None, server: str | None = None, @@ -397,6 +399,7 @@ def __init__( created: str | None = None, ): self.id = id + self.name = name self.username = username self.description = description self.server = server diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py index 0daff945..53d3302f 100644 --- a/tests/unit/storage_boxes/test_client.py +++ b/tests/unit/storage_boxes/test_client.py @@ -78,6 +78,7 @@ class TestBoundStorageBox(BoundModelTestCase): BoundStorageBox.create_subaccount, BoundStorageBox.get_subaccount_all, BoundStorageBox.get_subaccount_by_id, + BoundStorageBox.get_subaccount_by_name, BoundStorageBox.get_subaccount_by_username, BoundStorageBox.get_subaccount_list, ] @@ -899,9 +900,28 @@ def test_get_subaccount_by_id( assert_bound_storage_box_subaccount(result, resource_client) + def test_get_subaccount_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_name(StorageBox(42), "subaccount1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"name": "subaccount1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + @pytest.mark.parametrize( "params", [ + {"name": "subaccount1"}, {"username": "u42-sub1"}, {"label_selector": "key=value"}, # {"page": 1, "per_page": 10} # No pagination @@ -950,6 +970,7 @@ def test_get_subaccount_list( @pytest.mark.parametrize( "params", [ + {"name": "subaccount1"}, {"username": "u42-sub1"}, {"label_selector": "key=value"}, {"sort": ["id:asc"]}, @@ -1029,6 +1050,7 @@ def test_create_subaccount( result = resource_client.create_subaccount( StorageBox(42), + name="subaccount1", home_directory="tmp", password="secret", access_settings=StorageBoxSubaccountAccessSettings( @@ -1044,6 +1066,7 @@ def test_create_subaccount( method="POST", url="/storage_boxes/42/subaccounts", json={ + "name": "subaccount1", "home_directory": "tmp", "password": "secret", "access_settings": { From 3320d2e738d17bbcf4c7f68c25ef52ecd67a8006 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 16 Jan 2026 11:18:36 +0100 Subject: [PATCH 161/213] chore(main): release v2.15.0 (#622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Features - add name to Storage Box Subaccount (#621) ---

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 1f424554..6c5377bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v2.15.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.15.0) + +### Features + +- add name to Storage Box Subaccount (#621) + ## [v2.14.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.14.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 79280c74..8a06bd24 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.14.0" # x-releaser-pleaser-version +__version__ = "2.15.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 4bd7d10c..96a902cd 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.14.0", # x-releaser-pleaser-version + version="2.15.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From 911f1ae53f1e4d5f1c864674728005adc328a97b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 09:10:52 +0100 Subject: [PATCH 162/213] chore(deps): update pre-commit hook psf/black-pre-commit-mirror to v26 (#623) 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 | major | `25.12.0` β†’ `26.1.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.1.0`](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.12.0...26.1.0) [Compare Source](https://redirect.github.com/psf/black-pre-commit-mirror/compare/25.12.0...26.1.0)
--- ### Configuration πŸ“… **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. β™» **Rebasing**: Whenever PR becomes conflicted, 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 0796b3cb..28d6d43d 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: 25.12.0 + rev: 26.1.0 hooks: - id: black From 5e0bd4fe94701ee9d3d8936f0b687b520848ee04 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 22 Jan 2026 11:16:39 +0100 Subject: [PATCH 163/213] ci: ignore spec reference links (#624) The spec reference anchors are no longer generated server side, lychee therefore cannot check them. Ignore all the reference links until a better solution is found. --- .github/workflows/links.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 6c1a78ec..cf99d202 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -31,6 +31,8 @@ jobs: --include-fragments --exclude 'api.hetzner.cloud' --exclude 'api.hetzner.com' + --exclude 'https://docs.hetzner.cloud/reference/cloud#' + --exclude 'https://docs.hetzner.cloud/reference/hetzner#' --exclude 'codecov.io' --exclude 'github.com' '**/*.md' From 80f4310bdb7ad22898c4094f9dd57abc2bc45b0c Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Thu, 22 Jan 2026 14:20:20 +0100 Subject: [PATCH 164/213] docs: add user-agent to client initialization examples (#625) --- README.md | 6 +++++- examples/create_server.py | 6 +++++- examples/get_server_metrics.py | 6 +++++- examples/list_servers.py | 6 +++++- examples/usage_oop.py | 6 +++++- examples/usage_procedurale.py | 6 +++++- 6 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6cab8a9a..50da524b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ from hcloud import Client from hcloud.images import Image from hcloud.server_types import ServerType -client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here +client = Client( + token="{YOUR_API_TOKEN}", # Please paste your API token here + application_name="my-app", + application_version="v1.0.0", +) # Create a server named my-server response = client.servers.create( diff --git a/examples/create_server.py b/examples/create_server.py index b9b7399c..7aea8fc7 100644 --- a/examples/create_server.py +++ b/examples/create_server.py @@ -11,7 +11,11 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) response = client.servers.create( name="my-server", diff --git a/examples/get_server_metrics.py b/examples/get_server_metrics.py index c3023e31..814a815c 100644 --- a/examples/get_server_metrics.py +++ b/examples/get_server_metrics.py @@ -12,7 +12,11 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) server = client.servers.get_by_name("my-server") if server is None: diff --git a/examples/list_servers.py b/examples/list_servers.py index 82524301..64cc10e7 100644 --- a/examples/list_servers.py +++ b/examples/list_servers.py @@ -9,6 +9,10 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) servers = client.servers.get_all() print(servers) diff --git a/examples/usage_oop.py b/examples/usage_oop.py index 4b677886..f788306d 100644 --- a/examples/usage_oop.py +++ b/examples/usage_oop.py @@ -12,7 +12,11 @@ token = environ["HCLOUD_TOKEN"] # Create a client -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) # Create 2 servers # Create 2 servers diff --git a/examples/usage_procedurale.py b/examples/usage_procedurale.py index 50d853ba..6c88cbe7 100644 --- a/examples/usage_procedurale.py +++ b/examples/usage_procedurale.py @@ -13,7 +13,11 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) # Create 2 servers response1 = client.servers.create( From 1941493bb43fdc7229259cc8e4043f9947d6b0bc Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:36:43 +0100 Subject: [PATCH 165/213] feat(servers): allow setting user_data for rebuild (#627) See https://docs.hetzner.cloud/changelog#2026-01-16-server-rebuild-with-user-data --------- Co-authored-by: Jonas L. --- hcloud/servers/client.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index cd3c2b07..1128f420 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -358,14 +358,16 @@ def create_image( 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. :param image: Image to use for the rebuilt server + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ - return self._client.rebuild(self, image=image) + return self._client.rebuild(self, image=image, user_data=user_data) def change_type( self, @@ -1036,6 +1038,7 @@ def rebuild( self, server: Server | BoundServer, image: Image | BoundImage, + user_data: str | None = None, # pylint: disable=unused-argument **kwargs: Any, ) -> RebuildResponse: @@ -1043,8 +1046,12 @@ def rebuild( :param server: Server to rebuild :param image: Image to use for the rebuilt server + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ data: dict[str, Any] = {"image": image.id_or_name} + if user_data is not None: + data["user_data"] = user_data + response = self._client.request( url=f"{self._base_url}/{server.id}/actions/rebuild", method="POST", From ff2c3949126c07b19c418935c15d3accb33b5531 Mon Sep 17 00:00:00 2001 From: phm07 <22707808+phm07@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:36:56 +0100 Subject: [PATCH 166/213] feat: Storage Box support no longer experimental (#626) --- hcloud/storage_box_types/client.py | 15 --- hcloud/storage_boxes/client.py | 197 ----------------------------- 2 files changed, 212 deletions(-) diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py index 25c1e890..20e82086 100644 --- a/hcloud/storage_box_types/client.py +++ b/hcloud/storage_box_types/client.py @@ -31,9 +31,6 @@ class StorageBoxTypesClient(ResourceClientBase): A client for the Storage Box Types API. See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ _base_url = "/storage_box_types" @@ -49,9 +46,6 @@ def get_by_id(self, id: int) -> BoundStorageBoxType: See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-get-a-storage-box-type :param id: ID of the Storage Box Type. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -66,9 +60,6 @@ def get_by_name(self, name: str) -> BoundStorageBoxType | None: See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types :param name: Name of the Storage Box Type. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_list, name=name) @@ -86,9 +77,6 @@ def get_list( :param name: Name of the Storage Box Type. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -120,9 +108,6 @@ def get_all( See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types :param name: Name of the Storage Box Type. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_list, diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py index e0c76798..e1ca5c4c 100644 --- a/hcloud/storage_boxes/client.py +++ b/hcloud/storage_boxes/client.py @@ -98,9 +98,6 @@ def get_actions_list( :param sort: Sort Actions by field and direction. :param page: Page number to get. :param per_page: Maximum number of Actions returned per page. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_actions_list( self, @@ -123,9 +120,6 @@ def get_actions( :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_actions( self, @@ -146,9 +140,6 @@ def update( :param name: Name of the Storage Box. :param labels: User-defined labels (key/value pairs) for the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update( self, @@ -161,9 +152,6 @@ def delete(self) -> DeleteStorageBoxResponse: Deletes a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete(self) @@ -180,9 +168,6 @@ def get_folders( See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box :param path: Relative path to list the folders from. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_folders( self, @@ -200,9 +185,6 @@ def change_protection( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection :param delete: Prevents the Storage Box from being deleted. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_protection( self, @@ -219,9 +201,6 @@ def change_type( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type :param storage_box_type: Storage Box Type to change to. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_type( self, @@ -238,9 +217,6 @@ def reset_password( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password :param password: New password. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.reset_password( self, @@ -257,9 +233,6 @@ def update_access_settings( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings :param access_settings: New access settings for the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_access_settings( self, @@ -276,9 +249,6 @@ def rollback_snapshot( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot :param snapshot: Snapshot to rollback to. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.rollback_snapshot( self, @@ -292,9 +262,6 @@ def disable_snapshot_plan( Disable the snapshot plan of a Storage Box. See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.disable_snapshot_plan(self) @@ -308,9 +275,6 @@ def enable_snapshot_plan( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan :param snapshot_plan: Snapshot Plan to enable. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.enable_snapshot_plan( self, @@ -330,9 +294,6 @@ def get_snapshot_by_id( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot :param id: ID of the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_by_id(self, id=id) @@ -346,9 +307,6 @@ def get_snapshot_by_name( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots :param name: Name of the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_by_name(self, name=name) @@ -369,9 +327,6 @@ def get_snapshot_list( :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_list( self, @@ -398,9 +353,6 @@ def get_snapshot_all( :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_snapshot_all( self, @@ -423,9 +375,6 @@ def create_snapshot( :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.create_snapshot( self, @@ -446,9 +395,6 @@ def get_subaccount_by_id( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount :param id: ID of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_by_id(self, id=id) @@ -462,9 +408,6 @@ def get_subaccount_by_name( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts :param name: Name of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_by_name(self, name=name) @@ -478,9 +421,6 @@ def get_subaccount_by_username( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts :param username: User name of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_by_username(self, username=username) @@ -501,9 +441,6 @@ def get_subaccount_list( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_list( self, @@ -530,9 +467,6 @@ def get_subaccount_all( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.get_subaccount_all( self, @@ -564,9 +498,6 @@ def create_subaccount( :param access_settings: Access settings of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.create_subaccount( self, @@ -623,9 +554,6 @@ def update( :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_snapshot( self, @@ -640,9 +568,6 @@ def delete( Deletes a Storage Box Snapshot. See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete_snapshot(self) @@ -695,9 +620,6 @@ def update( :param name: Name of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_subaccount( self, @@ -713,9 +635,6 @@ def delete( Deletes a Storage Box Subaccount. See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.delete_subaccount(self) @@ -729,9 +648,6 @@ def change_home_directory( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory :param home_directory: Home directory for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.change_subaccount_home_directory( self, home_directory=home_directory @@ -748,8 +664,6 @@ def reset_password( :param password: Password for the Subaccount. - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.reset_subaccount_password(self, password=password) @@ -763,9 +677,6 @@ def update_access_settings( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings :param access_settings: Access settings for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._client.update_subaccount_access_settings( self, @@ -796,9 +707,6 @@ class StorageBoxesClient( A client for the Storage Boxes API. See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ _base_url = "/storage_boxes" @@ -821,9 +729,6 @@ def get_by_id(self, id: int) -> BoundStorageBox: See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box :param id: ID of the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -838,9 +743,6 @@ def get_by_name(self, name: str) -> BoundStorageBox | None: See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes :param name: Name of the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_list, name=name) @@ -863,9 +765,6 @@ def get_list( :param sort: Sort resources by field and direction. :param page: Page number to return. :param per_page: Maximum number of entries returned per page. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -904,9 +803,6 @@ def get_all( :param name: Name of the Storage Box. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_list, @@ -938,9 +834,6 @@ def create( :param ssh_keys: SSH public keys of the Storage Box. :param access_settings: Access settings of the Storage Box. :param labels: User-defined labels (key/value pairs) for the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "name": name, @@ -984,9 +877,6 @@ def update( :param storage_box: Storage Box to update. :param name: Name of the Storage Box. :param labels: User-defined labels (key/value pairs) for the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if name is not None: @@ -1012,9 +902,6 @@ def delete( See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box :param storage_box: Storage Box to delete. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="DELETE", @@ -1040,9 +927,6 @@ def get_folders( :param storage_box: Storage Box to list the folders from. :param path: Relative path to list the folders from. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if path is not None: @@ -1075,9 +959,6 @@ def get_actions_list( :param sort: Sort Actions by field and direction. :param page: Page number to get. :param per_page: Maximum number of Actions returned per page. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_actions_list( f"{self._base_url}/{storage_box.id}", @@ -1102,9 +983,6 @@ def get_actions( :param storage_box: Storage Box to get the Actions for. :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._iter_pages( self.get_actions_list, @@ -1126,9 +1004,6 @@ def change_protection( :param storage_box: Storage Box to update. :param delete: Prevents the Storage Box from being deleted. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if delete is not None: @@ -1153,9 +1028,6 @@ def change_type( :param storage_box: Storage Box to update. :param storage_box_type: Storage Box Type to change to. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "storage_box_type": storage_box_type.id_or_name, @@ -1180,9 +1052,6 @@ def reset_password( :param storage_box: Storage Box to update. :param password: New password. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "password": password, @@ -1207,9 +1076,6 @@ def update_access_settings( :param storage_box: Storage Box to update. :param access_settings: New access settings for the Storage Box. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = access_settings.to_payload() @@ -1232,9 +1098,6 @@ def rollback_snapshot( :param storage_box: Storage Box to update. :param snapshot: Snapshot to rollback to. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "snapshot": snapshot.id_or_name, @@ -1257,9 +1120,6 @@ def disable_snapshot_plan( See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan :param storage_box: Storage Box to update. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="POST", @@ -1279,9 +1139,6 @@ def enable_snapshot_plan( :param storage_box: Storage Box to update. :param snapshot_plan: Snapshot Plan to enable. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = snapshot_plan.to_payload() @@ -1307,9 +1164,6 @@ def get_snapshot_by_id( :param storage_box: Storage Box to get the Snapshot from. :param id: ID of the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -1329,9 +1183,6 @@ def get_snapshot_by_name( :param storage_box: Storage Box to get the Snapshot from. :param name: Name of the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by(self.get_snapshot_list, storage_box, name=name) @@ -1354,9 +1205,6 @@ def get_snapshot_list( :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -1399,9 +1247,6 @@ def get_snapshot_all( :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ # The endpoint does not have pagination, forward to the list method. result, _ = self.get_snapshot_list( @@ -1428,9 +1273,6 @@ def create_snapshot( :param storage_box: Storage Box to create a Snapshot from. :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = {} if description is not None: @@ -1468,9 +1310,6 @@ def update_snapshot( :param snapshot: Storage Box Snapshot to update. :param description: Description of the Snapshot. :param labels: User-defined labels (key/value pairs) for the Snapshot. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if snapshot.storage_box is None: raise ValueError("snapshot storage_box property is none") @@ -1498,9 +1337,6 @@ def delete_snapshot( See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot :param snapshot: Storage Box Snapshot to delete. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if snapshot.storage_box is None: raise ValueError("snapshot storage_box property is none") @@ -1528,9 +1364,6 @@ def get_subaccount_by_id( :param storage_box: Storage Box to get the Subaccount from. :param id: ID of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ response = self._client.request( method="GET", @@ -1550,9 +1383,6 @@ def get_subaccount_by_name( :param storage_box: Storage Box to get the Subaccount from. :param name: Name of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by( self.get_subaccount_list, @@ -1572,9 +1402,6 @@ def get_subaccount_by_username( :param storage_box: Storage Box to get the Subaccount from. :param username: User name of the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ return self._get_first_by( self.get_subaccount_list, @@ -1601,9 +1428,6 @@ def get_subaccount_list( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ params: dict[str, Any] = {} if name is not None: @@ -1647,9 +1471,6 @@ def get_subaccount_all( :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. :param sort: Sort resources by field and direction. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ # The endpoint does not have pagination, forward to the list method. result, _ = self.get_subaccount_list( @@ -1684,9 +1505,6 @@ def create_subaccount( :param access_settings: Access settings of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ data: dict[str, Any] = { "home_directory": home_directory, @@ -1733,9 +1551,6 @@ def update_subaccount( :param name: Name of the Subaccount. :param description: Description of the Subaccount. :param labels: User-defined labels (key/value pairs) for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1765,9 +1580,6 @@ def delete_subaccount( See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount :param subaccount: Storage Box Subaccount to delete. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1792,9 +1604,6 @@ def change_subaccount_home_directory( :param subaccount: Storage Box Subaccount to update. :param home_directory: Home directory for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1822,9 +1631,6 @@ def reset_subaccount_password( :param subaccount: Storage Box Subaccount to update. :param password: Password for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") @@ -1852,9 +1658,6 @@ def update_subaccount_access_settings( :param subaccount: Storage Box Subaccount to update. :param access_settings: Access settings for the Subaccount. - - Experimental: - Storage Box support is experimental, breaking changes may occur within minor releases. """ if subaccount.storage_box is None: raise ValueError("subaccount storage_box property is none") From 3bbd5a126863ae0e5bede6be7c100c151477f0d1 Mon Sep 17 00:00:00 2001 From: Hetzner Cloud Bot <45457231+hcloud-bot@users.noreply.github.com> Date: Fri, 23 Jan 2026 13:09:24 +0100 Subject: [PATCH 167/213] chore(main): release v2.16.0 (#628) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Storage Boxes support is now generally available The experimental phase for Storage Boxes is over, and Storage Boxes support is now generally available. ### Features - **servers**: allow setting user_data for rebuild (#627) - Storage Box support no longer experimental (#626) ---

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 ### Storage Boxes support is now generally available The experimental phase for Storage Boxes is over, and Storage Boxes support is now generally available. ~~~~ ### Suffix / End This will be added to the end of the release notes. ~~~~rp-suffix ~~~~
Co-authored-by: Hetzner Cloud Bot <> --- CHANGELOG.md | 11 +++++++++++ hcloud/_version.py | 2 +- setup.py | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5377bc..a1b5c8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [v2.16.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.16.0) + +### Storage Boxes support is now generally available + +The experimental phase for Storage Boxes is over, and Storage Boxes support is now generally available. + +### Features + +- **servers**: allow setting user_data for rebuild (#627) +- Storage Box support no longer experimental (#626) + ## [v2.15.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.15.0) ### Features diff --git a/hcloud/_version.py b/hcloud/_version.py index 8a06bd24..aa056a4a 100644 --- a/hcloud/_version.py +++ b/hcloud/_version.py @@ -1,3 +1,3 @@ from __future__ import annotations -__version__ = "2.15.0" # x-releaser-pleaser-version +__version__ = "2.16.0" # x-releaser-pleaser-version diff --git a/setup.py b/setup.py index 96a902cd..4ae4e5b9 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.15.0", # x-releaser-pleaser-version + version="2.16.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, From dfe08225c3660fc5a2d2a1c4bc64e250d3b14208 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:27:57 +0100 Subject: [PATCH 168/213] chore(deps): update pre-commit hook pycqa/isort to v8 (#629) --- .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 28d6d43d..c3f5ebdb 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: 7.0.0 + rev: 8.0.0 hooks: - id: isort From 1abe1be18f5a224b63aef914cf8f35df58eae3cd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:07:32 +0100 Subject: [PATCH 169/213] chore(deps): update apricote/releaser-pleaser action to v0.8.0 (#631) --- .github/workflows/releaser-pleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index 699110e4..ebbf620c 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.7.1 + uses: apricote/releaser-pleaser@v0.8.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | From 602703dfd3ff8c4f18d3bddd2aaefed12c2df071 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:09:59 +0100 Subject: [PATCH 170/213] chore(deps): update github artifact actions (major) (#630) --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78b5aaed..03e44dbb 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@v6 + uses: actions/upload-artifact@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@v7 + uses: actions/download-artifact@v8 with: name: python-packages path: dist/ From 7aaaa728daa3e341ab07c29206a6140ea41171eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:18:35 +0100 Subject: [PATCH 171/213] chore(deps): update pre-commit hook pycqa/isort to v8.0.1 (#632) --- .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 c3f5ebdb..d737c4a9 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.0 + rev: 8.0.1 hooks: - id: isort From cde61e5808580b3ad83bfa319ca1e9d8dc8b74f7 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 6 Mar 2026 01:47:16 +1000 Subject: [PATCH 172/213] 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 173/213] 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 174/213] 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 175/213] 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 176/213] 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 177/213] 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 178/213] 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 179/213] 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 180/213] 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 181/213] 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 182/213] 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 183/213] 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 184/213] 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 185/213] 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 186/213] 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 187/213] 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 188/213] 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 189/213] 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 190/213] 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 191/213] 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 192/213] 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 193/213] 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 194/213] 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 195/213] 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 196/213] 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 197/213] 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 198/213] 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 199/213] 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 200/213] 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 201/213] 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 202/213] 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 203/213] 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 204/213] 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 205/213] 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 206/213] 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 207/213] 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 208/213] 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 209/213] 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 210/213] 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 211/213] 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 212/213] 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 213/213] 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,