diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index cf99d202..8ca72686 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - uses: actions/cache@v5 + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: .lycheecache key: cache-lychee-${{ github.sha }} restore-keys: cache-lychee- - - uses: lycheeverse/lychee-action@v2 + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: fail: true args: > @@ -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' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d0a893a0..bd56f643 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,12 +9,12 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: pip install pre-commit @@ -25,12 +25,12 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: make venv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03e44dbb..bf955cc6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,12 +11,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: 3.x + python-version: "3.14" - name: Install dependencies run: pip install build twine @@ -29,7 +29,7 @@ jobs: - name: Upload packages artifact if: github.event_name == 'release' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # 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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 with: name: python-packages path: dist/ - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml index ebbf620c..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@v0.8.0 + uses: apricote/releaser-pleaser@93a3e2c401320206dcd9a9b7a5bbc0850072b3c6 # v0.9.0 with: token: ${{ secrets.HCLOUD_BOT_TOKEN }} extra-files: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 379c1fc6..6759f991 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,18 +12,18 @@ jobs: strategy: matrix: python-version: - - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15" name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup python - uses: actions/setup-python@v6 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 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@v5 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f2cc23b9..820385d0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -27,11 +27,11 @@ test: parallel: matrix: - python_version: - - "3.10" - "3.11" - "3.12" - "3.13" - "3.14" + - "3.15" image: python:${python_version}-alpine before_script: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d737c4a9..157f3753 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,29 +22,29 @@ 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.10 hooks: - id: prettier files: \.(md|ya?ml|js|css)$ - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade - args: [--py310-plus] + args: [--py311-plus] - repo: https://github.com/pycqa/isort - rev: 8.0.1 + rev: 9.0.2 hooks: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 26.1.0 + rev: 26.10.1 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.3.0 + rev: 7.4.1 hooks: - id: flake8 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: diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b5c8f4..795fe77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,188 @@ # Changelog +## [v2.27.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.27.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.26.0...v2.27.0) + +### Load Balancers can now be created with existing Primary IPs + +Primary IPs, which were previously available exclusively for Servers, are now also available for Load Balancers. When creating a Load Balancer, you can assign an [existing Primary IP](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/list_primary_ips) instead of having an IP address allocated automatically. Currently, you cannot change the Primary IP once the Load Balancer has been created. + +To support this change, we added the new `public_net` property to the request body of the endpoint [POST /load_balancers](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/create_load_balancer), with the following child properties: + +- `public_net.ipv4`: ID of an IPv4 Primary IP to assign to the Load Balancer. +- `public_net.ipv6`: ID of an IPv6 Primary IP to assign to the Load Balancer. + +If you omit a property, a new Primary IP of that version is allocated for the Load Balancer, as before. + +A Primary IP can currently only be assigned when the Load Balancer is created. It stays assigned for the entire lifetime of the Load Balancer. + +Related to https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips. + +### Deleting a Load Balancer now returns an Action + +> [!IMPORTANT] +> Please update all code that uses the `LoadBalancersClient.delete` method to wait on the newly returned action. + +As part of our work on supporting [existing Primary IPs for Load Balancers](https://docs.hetzner.cloud/changelog#2026-10-05-load-balancers-can-use-existing-primary-ips), deleting a Load Balancer is now tracked with an Action. + +The endpoint [DELETE /load_balancers/{id}](https://docs.hetzner.cloud/reference/cloud#tag/load-balancers/delete_load_balancer) now returns a 201 status code with an Action in the response body, instead of a 204 status code with an empty response body. + +You can use the returned Action to track the progress of the deletion. + +Related to https://docs.hetzner.cloud/changelog#2026-10-05-delete-load-balancer-returns-action. + +### Features + +- allow creating load balancers with primary ips (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) +- delete load balancer now returns an action (#700) ([84e3a19](https://github.com/hetznercloud/hcloud-python/commit/84e3a196ff29b2fc7508bce009b7d9da38005e49)) + +## [v2.26.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.26.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.1...v2.26.0) + +### Features + +- **load-balancer**: add health check diagnostic details (#696) ([80b74c9](https://github.com/hetznercloud/hcloud-python/commit/80b74c9a48091089f18160a083c447e04cb3eda7)) + +## [v2.25.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.1) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.25.0...v2.25.1) + +### Bug Fixes + +- only send load balancer service health_check properties when defined (#691) ([e3bf6f2](https://github.com/hetznercloud/hcloud-python/commit/e3bf6f2bcdb407607d7659ca4fe54bb47f6104c5)) + +## [v2.25.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.25.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.24.0...v2.25.0) + +### Features + +- list network members (#687) ([9d5d21d](https://github.com/hetznercloud/hcloud-python/commit/9d5d21db356c00fd09ef524d844a93c16552dc89)) + +### Bug Fixes + +- only send load balancer service certificates when defined (#690) ([c6bc24a](https://github.com/hetznercloud/hcloud-python/commit/c6bc24a0e733c84a91404677195fe237a859e495)) + +## [v2.24.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.24.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.23.0...v2.24.0) + +### Features + +- add deprecation info to image (#683) ([3bddb0d](https://github.com/hetznercloud/hcloud-python/commit/3bddb0d1d5726b8628152fcf1e5ef4104150b8b7)) + +## [v2.23.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.23.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.22.0...v2.23.0) + +### 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 + +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 + +- 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` + +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 + +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 + +`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 + +- missing `__api_properties__` on LoadBalancerService (#639) + +## [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/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/hcloud/_client.py b/hcloud/_client.py index 20f26c9d..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 @@ -129,6 +130,7 @@ class Client: - ``conflict`` - ``rate_limit_exceeded`` + - ``bad_gateway`` - ``timeout`` Changes to the retry policy might occur between releases, and will not be considered @@ -180,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 @@ -302,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__( @@ -420,6 +443,7 @@ def _retry_policy(self, exception: APIException) -> bool: return exception.code in ( "rate_limit_exceeded", "conflict", + "bad_gateway", "timeout", ) diff --git a/hcloud/_version.py b/hcloud/_version.py index aa056a4a..25e7bb72 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.27.0" # x-releaser-pleaser-version 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/core/domain.py b/hcloud/core/domain.py index 6659df80..cf7ddd19 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import Any, overload +from typing import Any, TypeVar, overload from dateutil.parser import isoparse @@ -12,12 +12,14 @@ "Meta", ] +T = TypeVar("T", bound="BaseDomain") + class BaseDomain: __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data: dict[str, Any]): # type: ignore[no-untyped-def] + def from_dict(cls: type[T], data: dict[str, Any]) -> T: """ Build the domain object from the data dict. """ diff --git a/hcloud/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 4867915d..aef7d700 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 @@ -17,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 @@ -24,8 +29,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, @@ -35,16 +41,48 @@ 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 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): """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/images/domain.py b/hcloud/images/domain.py index 915ace64..6e245bdc 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,8 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, TypedDict +import warnings +from datetime import datetime +from typing import TYPE_CHECKING, Any, TypedDict from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo if TYPE_CHECKING: from ..actions import BoundAction @@ -52,8 +55,10 @@ class Image(BaseDomain, DomainIdentityMixin): Protection configuration for the image :param deprecated: datetime, None Point in time when the image is considered to be deprecated (in ISO-8601 format) + :param deprecation: + Describes whether the resource is deprecated. :param labels: Dict - User-defined labels (key-value pairs) + User-defined labels (key-value pairs) """ __api_properties__ = ( @@ -73,11 +78,11 @@ class Image(BaseDomain, DomainIdentityMixin): "protection", "labels", "created", - "deprecated", + "deprecation", ) __slots__ = __api_properties__ - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,unused-argument def __init__( self, id: int | None = None, @@ -87,7 +92,7 @@ def __init__( description: str | None = None, image_size: int | None = None, disk_size: int | None = None, - deprecated: str | None = None, + deprecated: str | None = None, # Preserved for backward compatibility bound_to: Server | BoundServer | None = None, os_flavor: str | None = None, os_version: str | None = None, @@ -97,6 +102,7 @@ def __init__( protection: ImageProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -105,7 +111,6 @@ def __init__( self.description = description self.image_size = image_size self.disk_size = disk_size - self.deprecated = self._parse_datetime(deprecated) self.bound_to = bound_to self.os_flavor = os_flavor self.os_version = os_version @@ -115,6 +120,27 @@ def __init__( self.protection = protection self.labels = labels self.status = status + self.deprecation = ( + None if deprecation is None else DeprecationInfo.from_dict(deprecation) + ) + + @property + def deprecated(self) -> datetime | None: + """ + .. deprecated:: 2.24.0 + The 'deprecated' property is deprecated. Please use to the '.deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2026-09-08-removing-the-deprecated-field. + """ + warnings.warn( + "The 'deprecated' property is deprecated. Please use to the '.deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2026-09-08-removing-the-deprecated-field.", + DeprecationWarning, + stacklevel=2, + ) + if self.deprecation is None: + return None + return self.deprecation.announced class ImageProtection(TypedDict): diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py index 5fd50c6e..9ce845af 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -72,4 +72,4 @@ def deprecated(self) -> datetime | None: ) if self.deprecation is None: return None - return self.deprecation.unavailable_after # type: ignore[no-any-return] + return self.deprecation.unavailable_after 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/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 9b07d026..c68b2bbc 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -12,6 +12,7 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, @@ -36,6 +37,7 @@ "LoadBalancer", "LoadBalancerProtection", "LoadBalancerAlgorithm", + "LoadBalancerCreatePublicNetwork", "LoadBalancerHealtCheckHttp", "LoadBalancerHealthCheckHttp", "LoadBalancerHealthCheck", diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 1986905b..a1da3925 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -19,14 +19,17 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork +from ..primary_ips import BoundPrimaryIP from ..servers import BoundServer from .domain import ( CreateLoadBalancerResponse, + DeleteLoadBalancerResponse, GetMetricsResponse, IPv4Address, IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, LoadBalancerService, @@ -72,10 +75,23 @@ def __init__( public_net = data.get("public_net") if public_net: - ipv4_address = IPv4Address.from_dict(public_net["ipv4"]) - ipv6_network = IPv6Network.from_dict(public_net["ipv6"]) + if public_net_ipv4_id := public_net["ipv4"].get("id"): + public_net["ipv4"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv4_id}, + complete=False, + ) + if public_net_ipv6_id := public_net["ipv6"].get("id"): + public_net["ipv6"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv6_id}, + complete=False, + ) + data["public_net"] = PublicNetwork( - ipv4=ipv4_address, ipv6=ipv6_network, enabled=public_net["enabled"] + ipv4=IPv4Address.from_dict(public_net["ipv4"]), + ipv6=IPv6Network.from_dict(public_net["ipv6"]), + enabled=public_net["enabled"], ) private_nets = data.get("private_net") @@ -93,36 +109,53 @@ 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"], + detail=item.get("detail"), + http_status_code=item.get("http_status_code"), ) - 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: @@ -140,6 +173,7 @@ def __init__( 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( @@ -195,11 +229,8 @@ def update( """ return self._client.update(self, name=name, labels=labels) - def delete(self) -> bool: - """Deletes a Load Balancer. - - :return: boolean - """ + def delete(self) -> DeleteLoadBalancerResponse: + """Deletes a Load Balancer.""" return self._client.delete(self) def get_metrics( @@ -496,6 +527,7 @@ def create( location: Location | BoundLocation | None = None, network_zone: str | None = None, public_interface: bool | None = None, + public_net: LoadBalancerCreatePublicNetwork | None = None, network: Network | BoundNetwork | None = None, ) -> CreateLoadBalancerResponse: """Creates a Load Balancer . @@ -518,6 +550,8 @@ def create( The targets the Load Balancer is currently serving :param public_interface: bool Enable or disable the public interface of the Load Balancer + :param public_net: + Public network configuration for the Load Balancer. :param network: Network Adds the Load Balancer to a Network :return: :class:`CreateLoadBalancerResponse ` @@ -530,6 +564,8 @@ def create( data["network"] = network.id if public_interface is not None: data["public_interface"] = public_interface + if public_net is not None: + data["public_net"] = public_net.to_payload() if labels is not None: data["labels"] = labels if algorithm is not None: @@ -577,17 +613,20 @@ def update( ) return BoundLoadBalancer(self, response["load_balancer"]) - def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: + def delete( + self, load_balancer: LoadBalancer | BoundLoadBalancer + ) -> DeleteLoadBalancerResponse: """Deletes a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :return: boolean """ - self._client.request( - url=f"{self._base_url}/{load_balancer.id}", + response = self._client.request( method="DELETE", + url=f"{self._base_url}/{load_balancer.id}", + ) + return DeleteLoadBalancerResponse( + action=BoundAction(self._parent.actions, response["action"]) ) - return True def get_metrics( self, diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index c02e1100..5a527ee0 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -12,6 +12,7 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork, Network + from ..primary_ips import BoundPrimaryIP, PrimaryIP from ..servers import BoundServer from .client import BoundLoadBalancer @@ -160,6 +161,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, @@ -176,7 +187,7 @@ def __init__( self.health_check = health_check self.http = http - # pylint: disable=too-many-branches + # pylint: disable=too-many-branches,too-many-statements def to_payload(self) -> dict[str, Any]: """ Generates the request payload from this domain object. @@ -202,21 +213,17 @@ 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 - - http["certificates"] = [ - certificate.id for certificate in self.http.certificates or [] - ] + if self.http.timeout_idle is not None: + http["timeout_idle"] = self.http.timeout_idle + if self.http.certificates is not None: + http["certificates"] = [ + certificate.id for certificate in self.http.certificates + ] payload["http"] = http if self.health_check is not None: - health_check: dict[str, Any] = { - "protocol": self.health_check.protocol, - "port": self.health_check.port, - "interval": self.health_check.interval, - "timeout": self.health_check.timeout, - "retries": self.health_check.retries, - } + health_check: dict[str, Any] = {} if self.health_check.protocol is not None: health_check["protocol"] = self.health_check.protocol if self.health_check.port is not None: @@ -262,6 +269,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__ = ( @@ -270,6 +279,7 @@ class LoadBalancerServiceHttp(BaseDomain): "certificates", "redirect_http", "sticky_sessions", + "timeout_idle", ) __slots__ = __api_properties__ @@ -280,12 +290,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): @@ -411,6 +423,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 +434,7 @@ class LoadBalancerTarget(BaseDomain): "ip", "use_private_ip", "health_status", + "targets", ) __slots__ = __api_properties__ @@ -431,6 +446,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 +454,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]: """ @@ -472,11 +489,18 @@ class LoadBalancerTargetHealthStatus(BaseDomain): :param listen_port: Load Balancer Target listen port :param status: Load Balancer Target status. Choices: healthy, unhealthy, unknown + :param detail: Additional details about why the health check failed. Only present when `status` is `unhealthy`. + Choices: unspecified, layer4_no_connection, layer4_timeout, layer7_timeout, unexpected_http_status, + unexpected_http_content + :param http_status_code: HTTP status code returned by the target during the last health check. Only present when + `status` is `unhealthy` and `detail` is `unexpected_http_status`. """ __api_properties__ = ( "listen_port", "status", + "detail", + "http_status_code", ) __slots__ = __api_properties__ @@ -484,9 +508,13 @@ def __init__( self, listen_port: int | None = None, status: str | None = None, + detail: str | None = None, + http_status_code: int | None = None, ): self.listen_port = listen_port self.status = status + self.detail = detail + self.http_status_code = http_status_code class LoadBalancerTargetLabelSelector(BaseDomain): @@ -551,6 +579,41 @@ def __init__( self.enabled = enabled +class LoadBalancerCreatePublicNetwork(BaseDomain): + """Load Balancer Create Public Network Domain + + :param ipv4: ID of an existing Primary IP of type `ipv4` to assign to the Load Balancer. + :param ipv6: ID of an existing Primary IP of type `ipv6` to assign to the Load Balancer. + """ + + __api_properties__ = ( + "ipv4", + "ipv6", + ) + __slots__ = __api_properties__ + + def __init__( + self, + ipv4: PrimaryIP | BoundPrimaryIP | None = None, + ipv6: PrimaryIP | BoundPrimaryIP | None = None, + ): + self.ipv4 = ipv4 + self.ipv6 = ipv6 + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + + if self.ipv4 is not None: + payload["ipv4"] = self.ipv4.id + if self.ipv6 is not None: + payload["ipv6"] = self.ipv6.id + + return payload + + class IPv4Address(BaseDomain): """IPv4 Address Domain @@ -558,15 +621,25 @@ class IPv4Address(BaseDomain): The IPv4 Address """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr @@ -577,15 +650,25 @@ class IPv6Network(BaseDomain): The IPv6 Network as CIDR Notation """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr @@ -631,6 +714,21 @@ def __init__( self.action = action +class DeleteLoadBalancerResponse(BaseDomain): + """ + Delete Load Balancer Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + MetricsType = Literal[ "open_connections", "connections_per_second", diff --git a/hcloud/networks/__init__.py b/hcloud/networks/__init__.py index f49b9934..f0863c51 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -4,6 +4,9 @@ from .domain import ( CreateNetworkResponse, Network, + NetworkMember, + NetworkMemberStatus, + NetworkMemberType, NetworkProtection, NetworkRoute, NetworkSubnet, @@ -18,4 +21,7 @@ "NetworkSubnet", "NetworksClient", "NetworksPageResult", + "NetworkMemberType", + "NetworkMemberStatus", + "NetworkMember", ] diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index 73b33e5d..e884471c 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Literal, NamedTuple from ..actions import ( ActionSort, @@ -11,7 +11,14 @@ ) from ..actions.client import ResourceClientBaseActionsMixin from ..core import BoundModelBase, Meta, ResourceClientBase -from .domain import Network, NetworkRoute, NetworkSubnet +from .domain import ( + Network, + NetworkMember, + NetworkMemberStatus, + NetworkMemberType, + NetworkRoute, + NetworkSubnet, +) if TYPE_CHECKING: from .._client import Client @@ -89,6 +96,60 @@ def delete(self) -> bool: """ return self._client.delete(self) + def get_member_list( + self, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> NetworkMembersPageResult: + """ + Returns a paginated list of Members for the Network. + + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Members returned per page. + """ + return self._client.get_member_list( + self, + type=type, + status=status, + subnet=subnet, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_member_all( + self, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + ) -> list[NetworkMember]: + """ + Returns all of Members for the Network. + + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + """ + return self._client.get_member_all( + self, + type=type, + status=status, + subnet=subnet, + sort=sort, + ) + def get_actions_list( self, status: list[ActionStatus] | None = None, @@ -185,6 +246,27 @@ class NetworksPageResult(NamedTuple): meta: Meta +NetworkMemberSort = Literal[ + "id", + "id:asc", + "id:desc", + "type", + "type:asc", + "type:desc", + "status", + "status:asc", + "status:desc", + "ip", + "ip:asc", + "ip:desc", +] + + +class NetworkMembersPageResult(NamedTuple): + members: list[NetworkMember] + meta: Meta + + class NetworksClient( ResourceClientBaseActionsMixin, ResourceClientBase, @@ -372,6 +454,80 @@ def delete(self, network: Network | BoundNetwork) -> bool: self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") return True + def get_member_list( + self, + network: Network | BoundNetwork, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> NetworkMembersPageResult: + """ + Returns a paginated list of Members for a Network. + + :param network: Network to get the Members for. + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Members returned per page. + """ + params: dict[str, Any] = {} + if type is not None: + params["type"] = type + if status is not None: + params["status"] = status + if subnet is not None: + params["subnet"] = subnet + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + url=f"{self._base_url}/{network.id}/members", + method="GET", + params=params, + ) + + return NetworkMembersPageResult( + members=[NetworkMember(**o) for o in response["members"]], + meta=Meta.parse_meta(response), + ) + + def get_member_all( + self, + network: Network | BoundNetwork, + *, + type: list[NetworkMemberType] | None = None, + status: list[NetworkMemberStatus] | None = None, + subnet: list[str] | None = None, + sort: list[NetworkMemberSort] | None = None, + ) -> list[NetworkMember]: + """ + Returns all of Members for a Network. + + :param network: Network to get the Members for. + :param type: Filter the Members by type. + :param status: Filter the Members by status. + :param subnet: Filter the Members by the subnet they are attached to. + :param sort: Sort Members by field and direction. + """ + return self._iter_pages( + self.get_member_list, + network, + type=type, + status=status, + subnet=subnet, + sort=sort, + ) + def get_actions_list( self, network: Network | BoundNetwork, diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 80c3badc..9fd795e8 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -16,6 +16,9 @@ "NetworkSubnet", "NetworkRoute", "CreateNetworkResponse", + "NetworkMemberType", + "NetworkMemberStatus", + "NetworkMember", ] @@ -180,3 +183,74 @@ def __init__( ): self.network = network self.action = action + + +NetworkMemberType = Literal[ + "server", + "load_balancer", +] + +NetworkMemberStatus = Literal[ + "ok", + "attaching", + "detaching", + "updating", + "error", +] + + +class NetworkMember(BaseDomain): + """Network Member Domain + + :param id: ID of the resource attached to the Network. + :param type: Type of the resource attached to the Network. + :param ip: IP address of the resource within the Network. + :param alias_ips: Additional IP address of the resource within the Network. + :param subnet: IP range of the subnet the resource is attached to. + :param status: Status of the resource within the Network. + """ + + TYPE_SERVER: NetworkMemberType = "server" + """The member is a Server.""" + TYPE_LOAD_BALANCER: NetworkMemberType = "load_balancer" + """The member is a Load Balancer.""" + + STATUS_OK: NetworkMemberStatus = "ok" + """The resource is attached and its network configuration is up to date.""" + STATUS_ATTACHING: NetworkMemberStatus = "attaching" + """The resource is being attached to the Network.""" + STATUS_DETACHING: NetworkMemberStatus = "detaching" + """The resource is being detached from the Network.""" + STATUS_UPDATING: NetworkMemberStatus = "updating" + """The resource network configuration is being updated.""" + STATUS_ERROR: NetworkMemberStatus = "error" + """ + The last operation on this member failed. Its network configuration might be out of date, + and the resource might not be reachable within the Network. + """ + + __api_properties__ = ( + "id", + "type", + "ip", + "alias_ips", + "subnet", + "status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int, + type: NetworkMemberType, + ip: str, + alias_ips: list[str], + subnet: str, + status: NetworkMemberStatus, + ): + self.id = id + self.type = type + self.ip = ip + self.alias_ips = alias_ips + self.subnet = subnet + self.status = status diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index 6d6693dc..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,13 +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: - data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) - raw = data.get("location", {}) if raw: data["location"] = BoundLocation(client._parent.locations, raw) @@ -309,9 +303,8 @@ def create( self, type: str, 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, @@ -320,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) @@ -328,30 +320,34 @@ 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. " - "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 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/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/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/hcloud/servers/client.py b/hcloud/servers/client.py index 1128f420..ce5a72c0 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,10 +72,6 @@ def __init__( data: dict[str, Any], complete: bool = True, ): - 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) @@ -355,11 +348,11 @@ def create_image( self, description=description, type=type, labels=labels ) + # pylint: disable=unused-argument def rebuild( self, image: Image | BoundImage, user_data: str | None = None, - # pylint: disable=unused-argument **kwargs: Any, ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. @@ -634,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, @@ -659,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) @@ -679,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: @@ -1034,12 +1016,12 @@ def create_image( image=BoundImage(self._parent.images, response["image"]), ) + # pylint: disable=unused-argument def rebuild( self, server: Server | BoundServer, image: Image | BoundImage, user_data: str | None = None, - # pylint: disable=unused-argument **kwargs: Any, ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. diff --git a/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/pyproject.toml b/pyproject.toml index cbf37daf..8bb90a3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pylint.main] -py-version = "3.10" +py-version = "3.11" recursive = true jobs = 0 diff --git a/setup.py b/setup.py index 4ae4e5b9..e1988103 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name="hcloud", - version="2.16.0", # x-releaser-pleaser-version + version="2.27.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, @@ -27,13 +27,13 @@ "Intended Audience :: Developers", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", ], - python_requires=">=3.10", + python_requires=">=3.11", install_requires=[ "python-dateutil>=2.7.5", "requests>=2.20", @@ -42,15 +42,15 @@ "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": [ - "coverage>=7.13,<7.14", - "pylint>=4,<4.1", - "pytest>=9,<9.1", - "pytest-cov>=7,<7.1", - "mypy>=1.19,<1.20", + "coverage>=7.16,<7.17", + "pylint>=4,<4.2", + "pytest>=9,<9.2", + "pytest-cov>=7,<7.2", + "mypy>=2.4,<2.5", "types-python-dateutil", "types-requests", ], 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/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 diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 7f5ca96b..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): @@ -25,42 +28,48 @@ 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: @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", diff --git a/tests/unit/images/conftest.py b/tests/unit/images/conftest.py index 0917fbae..5c83fa34 100644 --- a/tests/unit/images/conftest.py +++ b/tests/unit/images/conftest.py @@ -4,141 +4,60 @@ @pytest.fixture() -def image_response(): +def image1(): return { - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": 1, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } + "id": 45557056, + "name": "debian-11", + "architecture": "x86", + "bound_to": None, + "created": "2021-08-16T11:12:01Z", + "created_from": None, + "deleted": None, + "deprecation": { + "announced": "2026-08-31T06:21:44Z", + "unavailable_after": "2026-12-01T00:00:00Z", + }, + "description": "Debian 11", + "disk_size": 5, + "image_size": None, + "labels": {}, + "os_flavor": "debian", + "os_version": "11", + "protection": { + "delete": False, + }, + "rapid_deploy": True, + "status": "available", + "type": "system", } @pytest.fixture() -def two_images_response(): +def image2(): return { - "images": [ - { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - }, - { - "id": 4712, - "type": "system", - "status": "available", - "name": "ubuntu-18.10", - "description": "Ubuntu 18.10 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - }, - ] - } - - -@pytest.fixture() -def one_images_response(): - return { - "images": [ - { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-20.04", - "description": "Ubuntu 20.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "x86", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } - ] - } - - -@pytest.fixture() -def response_update_image(): - return { - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": None, - "description": "My new Image description", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": {"id": 1, "name": "Server"}, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "architecture": "arm", - "rapid_deploy": False, - "protection": {"delete": False}, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {}, - } - } - - -@pytest.fixture() -def response_get_actions(): - return { - "actions": [ - { - "id": 13, - "command": "change_protection", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "image"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - ] + "id": 429564329, + "name": None, + "architecture": "x86", + "bound_to": 159969127, + "created": "2026-09-08T13:44:08Z", + "created_from": { + "id": 159969127, + "name": "server1", + }, + "deleted": None, + "deprecation": None, + "description": "snapshot 2026-09-08T13:44:08Z", + "disk_size": 80, + "image_size": 0.7237785009765625, + "labels": { + "key": "value", + }, + "os_flavor": "debian", + "os_version": "13", + "protection": { + "delete": True, + }, + "rapid_deploy": False, + "status": "available", + "type": "snapshot", } diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index d88b36bf..0ae6b07f 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -1,16 +1,33 @@ from __future__ import annotations -import datetime -from datetime import timezone from unittest import mock import pytest +from dateutil.parser import isoparse from hcloud import Client from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_image1( + o: BoundImage, + client: ImagesClient, +): + assert isinstance(o, BoundImage) + assert o._client is client + assert o.id == 45557056 + + +def assert_bound_image2( + o: BoundImage, + client: ImagesClient, +): + assert isinstance(o, BoundImage) + assert o._client is client + assert o.id == 429564329 class TestBoundImage(BoundModelTestCase): @@ -25,62 +42,80 @@ def resource_client(self, client: Client): return client.images @pytest.fixture() - def bound_model(self, resource_client): - return BoundImage(resource_client, data=dict(id=14)) - - def test_init(self, image_response): - bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) - - assert bound_image.id == 4711 - assert bound_image.type == "snapshot" - assert bound_image.status == "available" - assert bound_image.name == "ubuntu-20.04" - assert bound_image.description == "Ubuntu 20.04 Standard 64 bit" - assert bound_image.image_size == 2.3 - assert bound_image.disk_size == 10 - assert bound_image.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=timezone.utc - ) - assert bound_image.os_flavor == "ubuntu" - assert bound_image.os_version == "16.04" - assert bound_image.architecture == "x86" - assert bound_image.rapid_deploy is False - assert bound_image.deprecated == datetime.datetime( - 2018, 2, 28, 0, 0, tzinfo=timezone.utc - ) - - assert isinstance(bound_image.created_from, BoundServer) - assert bound_image.created_from.id == 1 - assert bound_image.created_from.name == "Server" - assert bound_image.created_from.complete is False - - assert isinstance(bound_image.bound_to, BoundServer) - assert bound_image.bound_to.id == 1 - assert bound_image.bound_to.complete is False + def bound_model(self, resource_client, image1): + return BoundImage(resource_client, data=image1) + + def test_init(self, image1, image2): + o = BoundImage(client=mock.MagicMock(), data=image1) + + assert o.id == 45557056 + assert o.type == "system" + assert o.name == "debian-11" + assert o.architecture == "x86" + assert o.status == "available" + assert o.description == "Debian 11" + assert o.image_size is None + assert o.disk_size == 5 + assert o.created == isoparse("2021-08-16T11:12:01Z") + assert o.created_from is None + assert o.bound_to is None + assert o.os_flavor == "debian" + assert o.os_version == "11" + assert o.rapid_deploy is True + assert o.labels == {} + assert o.protection == {"delete": False} + with pytest.deprecated_call(): + assert o.deprecated == isoparse("2026-08-31T06:21:44Z") + assert o.deprecation.announced == isoparse("2026-08-31T06:21:44Z") + assert o.deprecation.unavailable_after == isoparse("2026-12-01T00:00:00Z") + + o = BoundImage(client=mock.MagicMock(), data=image2) + + assert o.id == 429564329 + assert o.type == "snapshot" + assert o.name is None + assert o.architecture == "x86" + assert o.status == "available" + assert o.description == "snapshot 2026-09-08T13:44:08Z" + assert o.image_size == 0.7237785009765625 + assert o.disk_size == 80 + assert o.created == isoparse("2026-09-08T13:44:08Z") + assert isinstance(o.created_from, BoundServer) + assert o.created_from.id == 159969127 + assert o.created_from.name == "server1" + assert isinstance(o.bound_to, BoundServer) + assert o.bound_to.id == 159969127 + assert o.os_flavor == "debian" + assert o.os_version == "13" + assert o.rapid_deploy is False + assert o.labels == {"key": "value"} + assert o.protection == {"delete": True} + with pytest.deprecated_call(): + assert o.deprecated is None + assert o.deprecation is None class TestImagesClient: @pytest.fixture() - def images_client(self, client: Client): - return ImagesClient(client) + def resource_client(self, client: Client): + return client.images def test_get_by_id( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - image_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = image_response + request_mock.return_value = {"image": image1} - image = images_client.get_by_id(1) + result = resource_client.get_by_id(1) request_mock.assert_called_with( method="GET", url="/images/1", ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" + + assert_bound_image1(result, resource_client) @pytest.mark.parametrize( "params", @@ -102,13 +137,14 @@ def test_get_by_id( def test_get_list( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - two_images_response, + resource_client: ImagesClient, + image1, + image2, params, ): - request_mock.return_value = two_images_response + request_mock.return_value = {"images": [image1, image2]} - result = images_client.get_list(**params) + result = resource_client.get_list(**params) request_mock.assert_called_with( method="GET", @@ -116,21 +152,11 @@ def test_get_list( params=params, ) - images = result.images assert result.meta is not None + assert len(result.images) == 2 - assert len(images) == 2 - - images1 = images[0] - images2 = images[1] - - assert images1._client is images_client - assert images1.id == 4711 - assert images1.name == "ubuntu-20.04" - - assert images2._client is images_client - assert images2.id == 4712 - assert images2.name == "ubuntu-18.10" + assert_bound_image1(result.images[0], resource_client) + assert_bound_image2(result.images[1], resource_client) @pytest.mark.parametrize( "params", @@ -149,13 +175,14 @@ def test_get_list( def test_get_all( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - two_images_response, + resource_client: ImagesClient, + image1, + image2, params, ): - request_mock.return_value = two_images_response + request_mock.return_value = {"images": [image1, image2]} - images = images_client.get_all(**params) + result = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) @@ -165,64 +192,46 @@ def test_get_all( params=params, ) - assert len(images) == 2 - - images1 = images[0] - images2 = images[1] - - assert images1._client is images_client - assert images1.id == 4711 - assert images1.name == "ubuntu-20.04" - - assert images2._client is images_client - assert images2.id == 4712 - assert images2.name == "ubuntu-18.10" + assert len(result) == 2 + assert_bound_image1(result[0], resource_client) + assert_bound_image2(result[1], resource_client) def test_get_by_name( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - one_images_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = one_images_response + request_mock.return_value = {"images": [image1]} with pytest.deprecated_call(): - image = images_client.get_by_name("ubuntu-20.04") - - params = {"name": "ubuntu-20.04"} + result = resource_client.get_by_name("debian-11") request_mock.assert_called_with( method="GET", url="/images", - params=params, + params={"name": "debian-11"}, ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" + assert_bound_image1(result, resource_client) def test_get_by_name_and_architecture( self, request_mock: mock.MagicMock, - images_client: ImagesClient, - one_images_response, + resource_client: ImagesClient, + image1, ): - request_mock.return_value = one_images_response - - image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") + request_mock.return_value = {"images": [image1]} - params = {"name": "ubuntu-20.04", "architecture": ["x86"]} + result = resource_client.get_by_name_and_architecture("debian-11", "x86") request_mock.assert_called_with( method="GET", url="/images", - params=params, + params={"name": "debian-11", "architecture": ["x86"]}, ) - assert image._client is images_client - assert image.id == 4711 - assert image.name == "ubuntu-20.04" - assert image.architecture == "x86" + assert_bound_image1(result, resource_client) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -230,14 +239,17 @@ def test_get_by_name_and_architecture( def test_update( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - response_update_image, + image1, ): - request_mock.return_value = response_update_image + request_mock.return_value = {"image": image1} - image = images_client.update( - image, description="My new Image description", type="snapshot", labels={} + image = resource_client.update( + image, + description="My new Image description", + type="snapshot", + labels={}, ) request_mock.assert_called_with( @@ -250,8 +262,7 @@ def test_update( }, ) - assert image.id == 4711 - assert image.description == "My new Image description" + assert_bound_image1(image, resource_client) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -259,22 +270,23 @@ def test_update( def test_change_protection( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - action_response, + action1_running, ): - request_mock.return_value = action_response + request_mock.return_value = {"action": action1_running} - action = images_client.change_protection(image, True) + action = resource_client.change_protection(image, True) request_mock.assert_called_with( method="POST", url="/images/1/actions/change_protection", - json={"delete": True}, + json={ + "delete": True, + }, ) - assert action.id == 1 - assert action.progress == 0 + assert_bound_action1(action, resource_client._parent.actions) @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] @@ -282,17 +294,16 @@ def test_change_protection( def test_delete( self, request_mock: mock.MagicMock, - images_client: ImagesClient, + resource_client: ImagesClient, image, - action_response, ): - request_mock.return_value = action_response + request_mock.return_value = {} - delete_success = images_client.delete(image) + result = resource_client.delete(image) request_mock.assert_called_with( method="DELETE", url="/images/1", ) - assert delete_success is True + assert result is True 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..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 @@ -17,31 +18,55 @@ 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, + } + ] + 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": ""}, {}] + "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 +76,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 +110,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" diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index f19508ea..c97c56ed 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -9,8 +9,22 @@ def response_load_balancer(): "load_balancer": { "id": 4711, "name": "Web Frontend", - "ipv4": "131.232.99.1", - "ipv6": "2001:db8::1", + "public_net": { + "enabled": True, + "ipv4": { + "id": 4712, + "ip": "131.232.99.1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + "ipv6": { + "id": 4713, + "ip": "2001:db8::1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + }, + "private_net": [{"network": 4711, "ip": "10.0.255.1"}], "location": { "id": 1, "name": "fsn1", @@ -62,6 +76,7 @@ def response_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -83,10 +98,39 @@ def response_load_balancer(): { "type": "server", "server": {"id": 80}, - "health_status": [{"listen_port": 443, "status": "healthy"}], + "health_status": [ + {"listen_port": 443, "status": "healthy"}, + { + "listen_port": 80, + "status": "unhealthy", + "detail": "unexpected_http_status", + "http_status_code": 503, + }, + ], "label_selector": None, "use_private_ip": False, - } + }, + { + "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": "unhealthy", + "detail": "unexpected_http_status", + "http_status_code": 503, + }, + ], + } + ], + }, ], "algorithm": {"type": "round_robin"}, } @@ -139,6 +183,7 @@ def response_create_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -237,6 +282,7 @@ def response_update_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -328,6 +374,7 @@ def response_simple_load_balancers(): "cookie_lifetime": 300, "certificates": [897], "redirect_http": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -412,6 +459,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 7ac3290c..7d7fc89b 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -1,27 +1,37 @@ from __future__ import annotations +from datetime import datetime, timezone from unittest import mock import pytest from hcloud import Client -from hcloud.load_balancer_types import LoadBalancerType +from hcloud.certificates import BoundCertificate +from hcloud.load_balancer_types import BoundLoadBalancerType, LoadBalancerType from hcloud.load_balancers import ( BoundLoadBalancer, + IPv4Address, + IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancersClient, LoadBalancerService, + LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + PrivateNet, + PublicNetwork, ) -from hcloud.locations import Location -from hcloud.networks import Network -from hcloud.servers import Server +from hcloud.locations import BoundLocation, Location +from hcloud.networks import BoundNetwork, Network +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP +from hcloud.servers import BoundServer, Server -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 class TestBoundLoadBalancer(BoundModelTestCase): @@ -52,13 +62,127 @@ def resource_client(self, client: Client): def bound_model(self, resource_client: LoadBalancersClient): return BoundLoadBalancer(resource_client, data=dict(id=1)) + # pylint: disable=too-many-statements def test_init(self, response_load_balancer): + o = BoundLoadBalancer( + client=mock.MagicMock(), data=response_load_balancer["load_balancer"] + ) + + assert o.id == 4711 + assert o.name == "Web Frontend" + assert o.created == datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + assert o.labels == {} + assert o.protection == {"delete": False} + assert o.outgoing_traffic == 123456 + assert o.ingoing_traffic == 123456 + assert o.included_traffic == 654321 + + assert isinstance(o.public_net, PublicNetwork) + assert o.public_net.enabled is True + assert isinstance(o.public_net.ipv4, IPv4Address) + assert isinstance(o.public_net.ipv4.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv4.primary_ip.id == 4712 + assert o.public_net.ipv4.primary_ip.complete is False + assert o.public_net.ipv4.ip == "131.232.99.1" + assert o.public_net.ipv4.blocked is False + assert o.public_net.ipv4.dns_ptr == "lb1.example.com" + assert isinstance(o.public_net.ipv6, IPv6Network) + assert isinstance(o.public_net.ipv6.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv6.primary_ip.id == 4713 + assert o.public_net.ipv6.primary_ip.complete is False + assert o.public_net.ipv6.ip == "2001:db8::1" + assert o.public_net.ipv6.blocked is False + assert o.public_net.ipv6.dns_ptr == "lb1.example.com" + + assert len(o.private_net) == 1 + assert isinstance(o.private_net[0], PrivateNet) + assert isinstance(o.private_net[0].network, BoundNetwork) + assert o.private_net[0].network.id == 4711 + assert o.private_net[0].network.complete is False + assert o.private_net[0].ip == "10.0.255.1" + + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.location.complete is True + + assert isinstance(o.load_balancer_type, BoundLoadBalancerType) + assert o.load_balancer_type.id == 1 + assert o.load_balancer_type.name == "lb11" + assert o.load_balancer_type.complete is True + + assert isinstance(o.algorithm, LoadBalancerAlgorithm) + assert o.algorithm.type == "round_robin" + + assert len(o.services) == 1 + service = o.services[0] + assert isinstance(service, LoadBalancerService) + assert service.protocol == "https" + assert service.listen_port == 443 + assert service.destination_port == 80 + assert service.proxyprotocol is False + assert isinstance(service.http, LoadBalancerServiceHttp) + assert service.http.cookie_name == "HCLBSTICKY" + assert service.http.cookie_lifetime == 300 + assert service.http.redirect_http is True + assert service.http.sticky_sessions is True + assert service.http.timeout_idle == 60 + assert len(service.http.certificates) == 1 + assert isinstance(service.http.certificates[0], BoundCertificate) + assert service.http.certificates[0].id == 897 + assert service.http.certificates[0].complete is False + assert isinstance(service.health_check, LoadBalancerHealthCheck) + assert service.health_check.protocol == "http" + assert service.health_check.port == 4711 + assert service.health_check.interval == 15 + assert service.health_check.timeout == 10 + assert service.health_check.retries == 3 + assert isinstance(service.health_check.http, LoadBalancerHealthCheckHttp) + assert service.health_check.http.domain == "example.com" + assert service.health_check.http.path == "/" + assert service.health_check.http.response == '{"status": "ok"}' + assert service.health_check.http.status_codes == [200] + assert service.health_check.http.tls is False + + assert len(o.targets) == 2 + target = o.targets[0] + assert isinstance(target, LoadBalancerTarget) + assert target.type == "server" + assert isinstance(target.server, BoundServer) + assert target.server.id == 80 + assert target.server.complete is False + assert target.use_private_ip is False + assert len(target.health_status) == 2 + assert target.health_status[0].listen_port == 443 + assert target.health_status[0].status == "healthy" + + def test_init_label_selector_nested_targets(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( client=mock.MagicMock(), data=response_load_balancer["load_balancer"] ) - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" + 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[0].detail is None + assert nested.health_status[0].http_status_code is None + assert nested.health_status[1].listen_port == 3000 + assert nested.health_status[1].status == "unhealthy" + assert nested.health_status[1].detail == "unexpected_http_status" + assert nested.health_status[1].http_status_code == 503 class TestLoadBalancerslient: @@ -202,12 +326,22 @@ def test_create( "my-balancer", load_balancer_type=LoadBalancerType(name="lb11"), location=Location(id=1), + public_net=LoadBalancerCreatePublicNetwork( + ipv6=PrimaryIP(id=56367), + ), ) request_mock.assert_called_with( method="POST", url="/load_balancers", - json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + json={ + "name": "my-balancer", + "load_balancer_type": "lb11", + "location": 1, + "public_net": { + "ipv6": 56367, + }, + }, ) bound_load_balancer = response.load_balancer @@ -301,18 +435,18 @@ def test_delete( request_mock: mock.MagicMock, resource_client: LoadBalancersClient, load_balancer, - action_response, + action1_running, ): - request_mock.return_value = action_response + request_mock.return_value = {"action": action1_running} - delete_success = resource_client.delete(load_balancer) + result = resource_client.delete(load_balancer) request_mock.assert_called_with( method="DELETE", url="/load_balancers/1", ) - assert delete_success is True + assert_bound_action1(result.action, resource_client._parent.actions) @pytest.mark.parametrize( "load_balancer", @@ -359,13 +493,39 @@ 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, + certificates=[], + ), + ) 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 diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 47b081f2..9889b730 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -89,3 +89,57 @@ def test_private_net_for(self): assert load_balancer.private_net_for(network1).network.id == 1 assert load_balancer.private_net_for(network3) is None + + +def test_load_balancer_service_to_payload_keeps_set_properties(): + health_check = LoadBalancerHealthCheck() + health_check.protocol = "tcp" + health_check.port = 80 + health_check.interval = 15 + health_check.timeout = 10 + health_check.retries = 3 + + service = LoadBalancerService( + protocol="tcp", + listen_port=80, + destination_port=80, + health_check=health_check, + ) + + payload = service.to_payload() + + assert payload["health_check"] == { + "protocol": "tcp", + "port": 80, + "interval": 15, + "timeout": 10, + "retries": 3, + } + + +def test_load_balancer_service_to_payload_omits_unset_properties(): + # Regression test for https://github.com/ansible-collections/hetzner.hcloud/issues/158 + # + # When only some of the health_check fields are set, the payload must not + # include the unset ones as `null`, otherwise the Hetzner Cloud API rejects + # the request with "invalid input in field 'health_check'". + health_check = LoadBalancerHealthCheck() + health_check.http = LoadBalancerHealthCheckHttp() + health_check.http.status_codes = ["404"] + health_check.http.tls = False + + service = LoadBalancerService( + protocol="https", + listen_port=443, + proxyprotocol=False, + health_check=health_check, + ) + + payload = service.to_payload() + + assert payload["health_check"] == { + "http": { + "status_codes": ["404"], + "tls": False, + }, + } diff --git a/tests/unit/networks/conftest.py b/tests/unit/networks/conftest.py index 69287d21..01b61db9 100644 --- a/tests/unit/networks/conftest.py +++ b/tests/unit/networks/conftest.py @@ -210,3 +210,27 @@ def response_get_actions(): } ] } + + +@pytest.fixture() +def network_member1(): + return { + "type": "server", + "id": 123, + "ip": "10.0.1.2", + "status": "attaching", + "alias_ips": ["10.0.1.20"], + "subnet": "10.0.1.0/24", + } + + +@pytest.fixture() +def network_member2(): + return { + "type": "load_balancer", + "id": 456, + "ip": "10.0.1.3", + "status": "updating", + "alias_ips": [], + "subnet": "10.0.1.0/24", + } diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 3f162b46..5c1184da 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -22,6 +22,8 @@ class TestBoundNetwork(BoundModelTestCase): methods = [ BoundNetwork.update, BoundNetwork.delete, + BoundNetwork.get_member_list, + BoundNetwork.get_member_all, BoundNetwork.add_subnet, BoundNetwork.delete_subnet, BoundNetwork.add_route, @@ -458,6 +460,92 @@ def test_delete( assert delete_success is True + @pytest.mark.parametrize( + "params", + [ + {"type": ["server"]}, + {"status": ["ok"]}, + {"subnet": ["10.0.1.0/24"]}, + {"sort": ["ip:asc"]}, + {"page": 1, "per_page": 50}, + {}, + ], + ) + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_get_member_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + network_member1, + network_member2, + params, + ): + request_mock.return_value = {"members": [network_member1, network_member2]} + + result = networks_client.get_member_list(network, **params) + + request_mock.assert_called_with( + method="GET", + url="/networks/1/members", + params=params, + ) + + assert result.meta is not None + assert len(result.members) == 2 + + assert result.members[0].id == 123 + assert result.members[0].type == "server" + + assert result.members[0].ip == "10.0.1.2" + assert result.members[0].alias_ips == ["10.0.1.20"] + assert result.members[0].subnet == "10.0.1.0/24" + assert result.members[0].status == "attaching" + + assert result.members[1].id == 456 + assert result.members[1].type == "load_balancer" + + @pytest.mark.parametrize( + "params", + [ + {"type": ["server"]}, + {"status": ["ok"]}, + {"subnet": ["10.0.1.0/24"]}, + {"sort": ["ip:asc"]}, + {}, + ], + ) + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_get_member_all( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + network_member1, + network_member2, + params, + ): + request_mock.return_value = {"members": [network_member1, network_member2]} + + result = networks_client.get_member_all(network, **params) + + request_mock.assert_called_with( + method="GET", + url="/networks/1/members", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert result[0].id == 123 + assert result[0].type == "server" + + assert result[1].id == 456 + assert result[1].type == "load_balancer" + @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) diff --git a/tests/unit/primary_ips/conftest.py b/tests/unit/primary_ips/conftest.py index 4a7b23c6..3d84d871 100644 --- a/tests/unit/primary_ips/conftest.py +++ b/tests/unit/primary_ips/conftest.py @@ -4,252 +4,48 @@ @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, + "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 all_primary_ips_response(): +def primary_ip2(): return { - "meta": { - "pagination": { - "last_page": 1, - "next_page": None, - "page": 1, - "per_page": 25, - "previous_page": None, - "total_entries": 1, - } + "id": 52, + "name": "primary-ip2", + "type": "ipv4", + "ip": "131.232.99.2", + "assignee_id": None, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "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"}, ], - } - - -@pytest.fixture() -def primary_ip_create_response(): - 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", - }, - "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", - }, - } - - -@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"}, - } - ] + "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..f8df0225 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -5,10 +5,24 @@ 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 -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 +43,81 @@ 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"] - ) - - 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 + def test_init(self, primary_ip1): + o = BoundPrimaryIP(client=mock.MagicMock(), data=primary_ip1) - assert bound_primary_ip.assignee_id == 17 - assert bound_primary_ip.assignee_type == "server" + 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 - with pytest.deprecated_call(): - datacenter = bound_primary_ip.datacenter + assert o.assignee_id == 17 + assert o.assignee_type == "server" - 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 isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" 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 +127,55 @@ def test_get_all( params=params, ) - assert len(bound_primary_ips) == 1 - - bound_primary_ip1 = bound_primary_ips[0] + assert len(result) == 2 + assert_bound_primary_ip1(result[0], resource_client) + assert_bound_primary_ip2(result[1], resource_client) - assert bound_primary_ip1._client is primary_ips_client - assert bound_primary_ip1.id == 42 - assert bound_primary_ip1.name == "my-resource" - - def test_create_with_datacenter( + def test_create_with_location( self, request_mock: mock.MagicMock, - primary_ips_client: PrimaryIPsClient, - primary_ip_response, + resource_client: PrimaryIPsClient, + primary_ip1, ): - request_mock.return_value = primary_ip_response - - with pytest.deprecated_call(): - response = primary_ips_client.create( - type="ipv6", - name="my-resource", - datacenter=Datacenter(name="datacenter"), - ) + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": None, + } + + result = resource_client.create( + type="ipv4", + name="primary-ip1", + location=Location(name="fsn1"), + ) request_mock.assert_called_with( method="POST", url="/primary_ips", json={ - "name": "my-resource", - "type": "ipv6", - "assignee_type": "server", - "datacenter": "datacenter", + "name": "primary-ip1", + "type": "ipv4", + "location": "fsn1", "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 +184,218 @@ 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) + + 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", [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) 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 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, 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, diff --git a/tox.ini b/tox.ini index 813895b3..2bb22fa9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py310, py311, py312, py313, py314 +envlist = py311, py312, py313, py314, py315 [testenv] passenv = FAKE_API_ENDPOINT @@ -10,8 +10,8 @@ commands = [gh-actions] python = - 3.10: py310 3.11: py311 3.12: py312 3.13: py313 3.14: py314 + 3.15: py315