diff --git a/.github/release-please-config.json b/.github/release-please-config.json deleted file mode 100644 index 4fccf055..00000000 --- a/.github/release-please-config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "bootstrap-sha": "155a565bd3dfbce3177554626ccea6ac35807786", - "include-component-in-tag": false, - "include-v-in-tag": true, - "packages": { - ".": { - "release-type": "python", - "package-name": "hcloud", - "extra-files": ["hcloud/__version__.py"] - } - } -} diff --git a/.github/release-please-manifest.json b/.github/release-please-manifest.json deleted file mode 100644 index fde23122..00000000 --- a/.github/release-please-manifest.json +++ /dev/null @@ -1 +0,0 @@ -{".":"1.35.0"} diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 00000000..2791863e --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,41 @@ +name: Links + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "43 12 * * *" + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + with: + fail: true + args: > + --root-dir ${{ github.workspace }} + --verbose + --no-progress + --cache + --max-cache-age 2d + --include-fragments + --exclude 'api.hetzner.cloud' + --exclude 'api.hetzner.com' + --exclude 'https://docs.hetzner.cloud/reference/cloud#' + --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 4b09e8e7..f1910d8d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,10 +9,10 @@ jobs: pre-commit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x @@ -25,10 +25,10 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml deleted file mode 100644 index ba90290e..00000000 --- a/.github/workflows/release-please.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Release-please - -on: - push: - branches: [main] - -jobs: - release-please: - # Do not run on forks. - if: github.repository == 'hetznercloud/hcloud-python' - - runs-on: ubuntu-latest - steps: - - uses: google-github-actions/release-please-action@v4 - with: - token: ${{ secrets.HCLOUD_BOT_TOKEN }} - config-file: .github/release-please-config.json - manifest-file: .github/release-please-manifest.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaa2976e..08d3de84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: 3.x @@ -29,7 +29,7 @@ jobs: - name: Upload packages artifact if: github.event_name == 'release' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: python-packages path: dist/ @@ -47,10 +47,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Download packages artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: python-packages path: dist/ - name: Publish packages to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.14 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml new file mode 100644 index 00000000..e810632f --- /dev/null +++ b/.github/workflows/releaser-pleaser.yml @@ -0,0 +1,29 @@ +name: Releaser-pleaser + +on: + push: + branches: [main] + pull_request_target: + types: + - edited + - labeled + - unlabeled + +concurrency: + group: releaser-pleaser + cancel-in-progress: true + +jobs: + releaser-pleaser: + # Do not run on forks. + if: github.repository == 'hetznercloud/hcloud-python' + + runs-on: ubuntu-latest + steps: + - name: releaser-pleaser + uses: apricote/releaser-pleaser@93a3e2c401320206dcd9a9b7a5bbc0850072b3c6 # v0.9.0 + with: + token: ${{ secrets.HCLOUD_BOT_TOKEN }} + extra-files: | + setup.py + hcloud/_version.py diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml deleted file mode 100644 index d9410ae9..00000000 --- a/.github/workflows/stale.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Close stale issues - -on: - schedule: - - cron: "30 12 * * *" - -jobs: - stale: - permissions: - issues: write - pull-requests: write - - uses: hetznercloud/.github/.github/workflows/stale.yml@main diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8503445..90181486 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,14 +11,19 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: ${{ matrix.python-version }} @@ -29,6 +34,9 @@ jobs: run: tox -- --cov --cov-report=xml - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + if: > + !startsWith(github.head_ref, 'renovate/') && + !startsWith(github.head_ref, 'releaser-pleaser--') + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f3cfb8c8..f2cc23b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,6 +3,7 @@ include: file: - default.yml - pre-commit.yml + - workflows/feature-branches.yml stages: - test @@ -13,7 +14,7 @@ pre-commit: lint: stage: test - image: python:3.12-alpine + image: python:3.14-alpine before_script: - apk add make bash - make venv @@ -25,7 +26,12 @@ test: parallel: matrix: - - python_version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + - python_version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" image: python:${python_version}-alpine before_script: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e1e8bc08..c35de9e0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ # See https://pre-commit.com/hooks.html for more hooks repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict @@ -22,30 +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.6 hooks: - id: prettier files: \.(md|ya?ml|js|css)$ - exclude: ^CHANGELOG.md$ - repo: https://github.com/asottile/pyupgrade - rev: v3.15.2 + rev: v3.21.2 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py310-plus] - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 8.0.1 hooks: - id: isort - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.3.0 + rev: 26.5.1 hooks: - id: black - repo: https://github.com/pycqa/flake8 - rev: 7.0.0 + rev: 7.3.0 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 42d0aa8c..2d744fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,340 +1,760 @@ # Changelog -## [1.35.0](https://github.com/hetznercloud/hcloud-python/compare/v1.34.0...v1.35.0) (2024-04-02) +## [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 -* add `include_deprecated` option when fetching images by name ([#375](https://github.com/hetznercloud/hcloud-python/issues/375)) ([6d86f86](https://github.com/hetznercloud/hcloud-python/commit/6d86f86677fec23e6fd8a69d20d787e234e0fb53)) +- 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) -### Bug Fixes +### Datacenters resource is now deprecated -* raise warnings for the `ImagesClient.get_by_name` deprecation ([#376](https://github.com/hetznercloud/hcloud-python/issues/376)) ([b24de80](https://github.com/hetznercloud/hcloud-python/commit/b24de80684db142ebbe11b62a38d9c61f248e216)) +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`. -## [1.34.0](https://github.com/hetznercloud/hcloud-python/compare/v1.33.3...v1.34.0) (2024-03-27) +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 -* add `has_id_or_name` to `DomainIdentityMixin` ([#373](https://github.com/hetznercloud/hcloud-python/issues/373)) ([8facaf6](https://github.com/hetznercloud/hcloud-python/commit/8facaf6d4dd2bbfb4137e7066b49c5f4c1db773c)) +- deprecate datacenters (#656) -## [1.33.3](https://github.com/hetznercloud/hcloud-python/compare/v1.33.2...v1.33.3) (2024-03-27) +## [v2.21.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.21.0) +### Features -### Bug Fixes +- retry requests on api bad_gateway error (#658) -* invalid type for load balancer private network property ([#372](https://github.com/hetznercloud/hcloud-python/issues/372)) ([903e92f](https://github.com/hetznercloud/hcloud-python/commit/903e92faab745b7f8270f6195da67f4d9f8b1ba7)) +## [v2.20.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.20.0) +### Load Balancer HTTP Services now support `timeout_idle` -### Dependencies +HTTP Services now support the field `timeout_idle`, which controls the time a HTTP connection is allowed to idle before it is being dropped. -* update codecov/codecov-action action to v4 ([#359](https://github.com/hetznercloud/hcloud-python/issues/359)) ([a798979](https://github.com/hetznercloud/hcloud-python/commit/a79897977abe970181d19584e51448ff5976b5e2)) -* update dependency mypy to >=1.9,<1.10 ([#368](https://github.com/hetznercloud/hcloud-python/issues/368)) ([4b9328c](https://github.com/hetznercloud/hcloud-python/commit/4b9328ceae1e393ff55b3ca6f030cb5ac565be00)) -* update dependency pylint to >=3,<3.2 ([#364](https://github.com/hetznercloud/hcloud-python/issues/364)) ([d71d17f](https://github.com/hetznercloud/hcloud-python/commit/d71d17fd6f2968a8c19052753265ef7f514a8955)) -* update dependency pytest to >=8,<8.2 ([#366](https://github.com/hetznercloud/hcloud-python/issues/366)) ([8665dcf](https://github.com/hetznercloud/hcloud-python/commit/8665dcff335c755c1ff4d95621334a3f5e196d34)) -* update dependency pytest to v8 ([#357](https://github.com/hetznercloud/hcloud-python/issues/357)) ([f8f756f](https://github.com/hetznercloud/hcloud-python/commit/f8f756fe0a492e284bd2a700514c0ba38358b4a8)) -* update dependency pytest-cov to v5 ([#371](https://github.com/hetznercloud/hcloud-python/issues/371)) ([04a6a42](https://github.com/hetznercloud/hcloud-python/commit/04a6a42028606ed66657605d98b1f21545eb2e0d)) -* update dependency watchdog to v4 ([#360](https://github.com/hetznercloud/hcloud-python/issues/360)) ([cb8d383](https://github.com/hetznercloud/hcloud-python/commit/cb8d38396a8665506e3be64a09450343d7671586)) -* update pre-commit hook asottile/pyupgrade to v3.15.1 ([#362](https://github.com/hetznercloud/hcloud-python/issues/362)) ([dd2a521](https://github.com/hetznercloud/hcloud-python/commit/dd2a521eccec8e15b6d1d7fd843d866bf6ea5bcf)) -* update pre-commit hook asottile/pyupgrade to v3.15.2 ([3d02ad7](https://github.com/hetznercloud/hcloud-python/commit/3d02ad71e9200f5cc94b2d33eea62035edc1e33a)) -* update pre-commit hook psf/black-pre-commit-mirror to v24 ([#356](https://github.com/hetznercloud/hcloud-python/issues/356)) ([b46397d](https://github.com/hetznercloud/hcloud-python/commit/b46397d761caa60014bd32f7142b79bef9a92e18)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.1.1 ([#358](https://github.com/hetznercloud/hcloud-python/issues/358)) ([7e4645e](https://github.com/hetznercloud/hcloud-python/commit/7e4645e3e38a106f38a7f63810d71a628fead939)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.2.0 ([#361](https://github.com/hetznercloud/hcloud-python/issues/361)) ([5b56ace](https://github.com/hetznercloud/hcloud-python/commit/5b56ace93b8b4fddddbf5610c11fd20bf6f9a561)) -* update pre-commit hook psf/black-pre-commit-mirror to v24.3.0 ([3bbac5d](https://github.com/hetznercloud/hcloud-python/commit/3bbac5dc41ca509d6679fd6b06ae99ca33fd62ee)) -* update pre-commit hook pycqa/flake8 to v7 ([#354](https://github.com/hetznercloud/hcloud-python/issues/354)) ([66a582f](https://github.com/hetznercloud/hcloud-python/commit/66a582f3ce728d92045625885d0634fc96fbc6a0)) -* update pypa/gh-action-pypi-publish action to v1.8.12 ([#365](https://github.com/hetznercloud/hcloud-python/issues/365)) ([55db255](https://github.com/hetznercloud/hcloud-python/commit/55db2551dd0f0ea6a29da4e7a6dce2af8de86eaf)) -* update pypa/gh-action-pypi-publish action to v1.8.14 ([#367](https://github.com/hetznercloud/hcloud-python/issues/367)) ([0cb615f](https://github.com/hetznercloud/hcloud-python/commit/0cb615fe0d852cddbf636c1fdb8538ad60f5a3d9)) +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. -## [1.33.2](https://github.com/hetznercloud/hcloud-python/compare/v1.33.1...v1.33.2) (2024-01-02) +### Features +- **load-balancer**: support `timeout_idle` http service field (#649) -### Bug Fixes +## [v2.19.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.19.0) -* publish package to PyPI using OIDC auth ([1a0e93b](https://github.com/hetznercloud/hcloud-python/commit/1a0e93bbf1ae6cc747e6c4d8305dafd3e49dbbdc)) +### Primary IPs `assignee_type` behavior change -## [1.33.1](https://github.com/hetznercloud/hcloud-python/compare/v1.33.0...v1.33.1) (2024-01-02) +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 -* private object not exported in top level module ([#346](https://github.com/hetznercloud/hcloud-python/issues/346)) ([5281b05](https://github.com/hetznercloud/hcloud-python/commit/5281b0583541b6e0e9b8c7ad75faa42c5d379735)) +- missing `__api_properties__` on LoadBalancerService (#639) +## [v2.17.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.0) -### Dependencies +### Features -* update dependency coverage to >=7.4,<7.5 ([#348](https://github.com/hetznercloud/hcloud-python/issues/348)) ([3ac5711](https://github.com/hetznercloud/hcloud-python/commit/3ac57117e8a68a02cba19c56f850f037c4aca462)) -* update dependency mypy to >=1.8,<1.9 ([#343](https://github.com/hetznercloud/hcloud-python/issues/343)) ([984022f](https://github.com/hetznercloud/hcloud-python/commit/984022fd3888ef856be83de82554d55a8af18dba)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.12.1 ([#347](https://github.com/hetznercloud/hcloud-python/issues/347)) ([2c24efe](https://github.com/hetznercloud/hcloud-python/commit/2c24efe93bc221846f8dcc91abcf1aad61547875)) +- parse nested load balancer `label_selector` targets (#633) -## [1.33.0](https://github.com/hetznercloud/hcloud-python/compare/v1.32.0...v1.33.0) (2023-12-19) +## [v2.16.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.16.0) + +### Storage Boxes support is now generally available + +The experimental phase for Storage Boxes is over, and Storage Boxes support is now generally available. + +### Features + +- **servers**: allow setting user_data for rebuild (#627) +- Storage Box support no longer experimental (#626) + +## [v2.15.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.15.0) + +### Features + +- add name to Storage Box Subaccount (#621) + +## [v2.14.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.14.0) + +### Features + +- retry requests when the api returns a `timeout` error (#617) + +## [v2.13.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.13.0) + +### Features + +- add per primary ip actions list operations (#608) +- deprecate datacenter in `primary ips` and `servers` (#609) + +## [v2.12.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.12.0) + +### Storage Box API Experimental +This release adds support for the [Storage Box API](https://docs.hetzner.cloud/reference/hetzner#storage-boxes). + +The Storage Box integration will be introduced as an **experimental** feature. This experimental phase is expected to last at least until **12 January 2026**. During this period, upcoming minor releases of the project may include breaking changes to features related to Storage Boxes. + +This release includes all changes from the recent [Storage Box API changelog](https://docs.hetzner.cloud/changelog#2025-10-21-storage-box-api-update) entry. + +#### Examples + +```python +response = client.storage_boxes.create( + name="string", + location=Location(name="fsn1"), + storage_box_type=StorageBoxType(name="bx11"), + labels={ + "environment": "prod", + "example.com/my": "label", + "just-a-key": "", + }, + password="my-password", + access_settings=StorageBoxAccessSettings( + reachable_externally=False, + samba_enabled=False, + ssh_enabled=False, + webdav_enabled=False, + zfs_enabled=False, + ), + ssh_keys=[SSHKey(public_key="ssh-rsa AAAjjk76kgf...Xt")], +) + +response.action.wait_until_finished() + +storage_box = response.storage_box +``` ### Features -* add metrics endpoint for load balancers and servers ([#331](https://github.com/hetznercloud/hcloud-python/issues/331)) ([ee3c54f](https://github.com/hetznercloud/hcloud-python/commit/ee3c54fd1b6963533bc9d1e1f9ff57f6c5872cd5)) +- add update rrset records action to zone client (#597) +- add support for Storage Boxes (#524) +## [v2.11.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.1) ### Bug Fixes -* fallback to error code when message is unset ([#328](https://github.com/hetznercloud/hcloud-python/issues/328)) ([1c94153](https://github.com/hetznercloud/hcloud-python/commit/1c94153d93acd567548604b08b5fabeabd8d33d9)) +- support reloading sub resource bound models (#590) +## [v2.11.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.0) -### Dependencies +### DNS API is now generally available -* update actions/setup-python action to v5 ([#335](https://github.com/hetznercloud/hcloud-python/issues/335)) ([2ac252d](https://github.com/hetznercloud/hcloud-python/commit/2ac252d18ba6079d5372c6ab9e3f67b4740db465)) -* update dependency sphinx-rtd-theme to v2 ([#330](https://github.com/hetznercloud/hcloud-python/issues/330)) ([7cc4335](https://github.com/hetznercloud/hcloud-python/commit/7cc4335cacab6073cf39a0ecbecf8890903d5bca)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.12.0 ([#338](https://github.com/hetznercloud/hcloud-python/issues/338)) ([38e4748](https://github.com/hetznercloud/hcloud-python/commit/38e4748d3d194d37ea3d0c63683609f5db432e0d)) -* update pre-commit hook pycqa/isort to v5.13.0 ([#336](https://github.com/hetznercloud/hcloud-python/issues/336)) ([3244cfe](https://github.com/hetznercloud/hcloud-python/commit/3244cfef2f90ef52d0fb791d514d6afe481aa4d7)) -* update pre-commit hook pycqa/isort to v5.13.1 ([#337](https://github.com/hetznercloud/hcloud-python/issues/337)) ([020a0ef](https://github.com/hetznercloud/hcloud-python/commit/020a0eff6bc2b63d16b339fd5d4c3ea3610c0509)) -* update pre-commit hook pycqa/isort to v5.13.2 ([#339](https://github.com/hetznercloud/hcloud-python/issues/339)) ([b46df8c](https://github.com/hetznercloud/hcloud-python/commit/b46df8cbb263945c59ce4408e0a7189d19d9c597)) +The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. -## [1.32.0](https://github.com/hetznercloud/hcloud-python/compare/v1.31.0...v1.32.0) (2023-11-17) +To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process/). +See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for more details. ### Features -* allow returning root_password in servers rebuild ([#276](https://github.com/hetznercloud/hcloud-python/issues/276)) ([38e098a](https://github.com/hetznercloud/hcloud-python/commit/38e098a41154e6561578cd723608fcd7577c3d01)) +- DNS support is now generally available (#581) +## [v2.10.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.10.0) -### Dependencies +### Features -* update dependency mypy to >=1.7,<1.8 ([#325](https://github.com/hetznercloud/hcloud-python/issues/325)) ([7b59a2d](https://github.com/hetznercloud/hcloud-python/commit/7b59a2decc9bb5152dc9de435bfe12ce1f34ac1c)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.1.0 ([#326](https://github.com/hetznercloud/hcloud-python/issues/326)) ([213b661](https://github.com/hetznercloud/hcloud-python/commit/213b661d897cdd327f478b52aeb79844826694d8)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.10.1 ([#322](https://github.com/hetznercloud/hcloud-python/issues/322)) ([999afe3](https://github.com/hetznercloud/hcloud-python/commit/999afe37e02a113639930aff6879f50918ac0e89)) -* update pre-commit hook psf/black-pre-commit-mirror to v23.11.0 ([#324](https://github.com/hetznercloud/hcloud-python/issues/324)) ([7b2a24e](https://github.com/hetznercloud/hcloud-python/commit/7b2a24ecf69c0bead7f9113053fda37a0cc31d1b)) +- **exp**: add zone format txt record helper (#578) +- add server and load balancer `private_net_for` helper method (#580) -## [1.31.0](https://github.com/hetznercloud/hcloud-python/compare/v1.30.0...v1.31.0) (2023-10-23) +## [v2.9.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.9.0) + +### Features + +- support python 3.14 (#566) +- drop support for python 3.9 (#574) + +## [v2.8.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.8.0) + +### DNS API Beta +This release adds support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). + +The DNS API is currently in **beta**, which will likely end on 10 November 2025. After the beta ended, it will no longer be possible to create new zones in the old DNS system. See the [DNS Beta FAQ](https://docs.hetzner.com/networking/dns/faq/beta/) for more details. + +Future minor releases of this project may include breaking changes for features that are related to the DNS API. + +See the [DNS API Beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. + +**Examples** + +```py +resp = client.zones.create( + name="example.com", + mode="primary", + labels={"key": "value"}, + rrsets=[ + ZoneRRSet( + name="@", + type="A", + records=[ + ZoneRecord(value="201.180.75.2", comment="server1") + ], + ) + ], +) + +resp.action.wait_until_finished() +zone = resp.zone +``` ### Features -* prepare for iso deprecated field removal ([#320](https://github.com/hetznercloud/hcloud-python/issues/320)) ([beae328](https://github.com/hetznercloud/hcloud-python/commit/beae328dd6b9afb8c0db9fa9b44340270db7dd09)) +- add new `ip_range` param to load balancer `attach_to_network` (#562) +- add new `ip_range` param to server `attach_to_network` (#561) +- support the new DNS API (#568) +### Bug Fixes -### Dependencies +- source_ips property is optional in firewall rule (#567) -* update pre-commit hook psf/black-pre-commit-mirror to v23.10.0 ([#319](https://github.com/hetznercloud/hcloud-python/issues/319)) ([184bbe6](https://github.com/hetznercloud/hcloud-python/commit/184bbe65a736a42d13774b6c29fa7dd8a13ec645)) +## [v2.7.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.7.0) -## [1.30.0](https://github.com/hetznercloud/hcloud-python/compare/v1.29.1...v1.30.0) (2023-10-13) +[Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). + +- We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. + +- We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. + +See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. + +**Upgrading** + +```py +# Before +def validate_server_type(server_type: ServerType): + if server_type.deprecation is not None: + raise ValueError(f"server type {server_type.name} is deprecated") +``` + +```py +# After +def validate_server_type(server_type: ServerType, location: Location): + found = [o for o in server_type.locations if location.name == o.location.name] + if not found: + raise ValueError( + f"server type {server_type.name} is not supported in location {location.name}" + ) + + server_type_location = found[0] + if server_type_location.deprecation is not None: + raise ValueError( + f"server type {server_type.name} is deprecated in location {location.name}" + ) +``` ### Features -* add deprecation field to Iso ([#318](https://github.com/hetznercloud/hcloud-python/issues/318)) ([036b52f](https://github.com/hetznercloud/hcloud-python/commit/036b52fe51bcbb6b610c0c99ca224d3c4bbfc68d)) -* support python 3.12 ([#311](https://github.com/hetznercloud/hcloud-python/issues/311)) ([7e8cd1d](https://github.com/hetznercloud/hcloud-python/commit/7e8cd1d92e56d210fe3fb180e403122ef0e7bd7f)) +- per location server types (#558) +## [v2.6.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.6.0) -### Dependencies +### Features -* update dependency mypy to >=1.6,<1.7 ([#317](https://github.com/hetznercloud/hcloud-python/issues/317)) ([d248bbd](https://github.com/hetznercloud/hcloud-python/commit/d248bbd4e55f3bcf6a107cfa4f38768df0bf3de5)) -* update dependency pylint to v3 ([#307](https://github.com/hetznercloud/hcloud-python/issues/307)) ([277841d](https://github.com/hetznercloud/hcloud-python/commit/277841dd84ba3b2bbc99a06a3f97e114d1c83dcb)) -* update pre-commit hook asottile/pyupgrade to v3.14.0 ([#308](https://github.com/hetznercloud/hcloud-python/issues/308)) ([07a4513](https://github.com/hetznercloud/hcloud-python/commit/07a4513e284b9ee964bca003d0a9dfd948d39b02)) -* update pre-commit hook asottile/pyupgrade to v3.15.0 ([#312](https://github.com/hetznercloud/hcloud-python/issues/312)) ([c544639](https://github.com/hetznercloud/hcloud-python/commit/c5446394acfa25d23761da4c6b5b75fb6d376b23)) -* update pre-commit hook pre-commit/pre-commit-hooks to v4.5.0 ([#313](https://github.com/hetznercloud/hcloud-python/issues/313)) ([e51eaa9](https://github.com/hetznercloud/hcloud-python/commit/e51eaa990336251c2afc8c83d4c5e6f5e5bb857b)) -* update python docker tag to v3.12 ([#309](https://github.com/hetznercloud/hcloud-python/issues/309)) ([3a1ee67](https://github.com/hetznercloud/hcloud-python/commit/3a1ee675f2c980a4d9e63188e8ffceb64f4797fc)) +- add category property to server type (#549) -## [1.29.1](https://github.com/hetznercloud/hcloud-python/compare/v1.29.0...v1.29.1) (2023-09-26) +### Bug Fixes +- rename `ClientEntityBase` to `ResourceClientBase` (#532) + +## [v2.5.4](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.4) ### Bug Fixes -* prevent api calls when printing bound models ([#305](https://github.com/hetznercloud/hcloud-python/issues/305)) ([c1de7ef](https://github.com/hetznercloud/hcloud-python/commit/c1de7efc851b3b10e2a50e66268fc8fb0ff648a8)) +- typo in `LoadBalancerHealthCheckHttp` class name (#511) +- equality for some domain classes (#510) +- use valid license identifier (SPDX) (#514) -## [1.29.0](https://github.com/hetznercloud/hcloud-python/compare/v1.28.0...v1.29.0) (2023-09-25) +## [v2.5.3](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.3) + +### Bug Fixes +- invalid placement group id casting (#501) +- handle string id when checking has_id_or_name (#504) + +## [v2.5.2](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.2) + +### Bug Fixes + +- listing page result always provide meta (#496) + +## [v2.5.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.1) + +### Bug Fixes + +- missing slots and api_properties for FirewallResourceLabelSelector (#492) + +## [v2.5.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.0) ### Features -* add domain attribute type hints to bound models ([#300](https://github.com/hetznercloud/hcloud-python/issues/300)) ([6d46d06](https://github.com/hetznercloud/hcloud-python/commit/6d46d06c42e2e86e88b32a74d7fbd588911cc8ad)) -* **firewalls:** add `applied_to_resources` to `FirewallResource` ([#297](https://github.com/hetznercloud/hcloud-python/issues/297)) ([55d2b20](https://github.com/hetznercloud/hcloud-python/commit/55d2b2043ec1e3a040eb9e360ca0dc0c299ad60f)) +- improve exception messages (#488) + +## [v2.4.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.4.0) + +### Features +- drop support for python 3.8 (#458) +- add equality checks to domains (#481) ### Bug Fixes -* missing BaseDomain base class inheritance ([#303](https://github.com/hetznercloud/hcloud-python/issues/303)) ([0ee7598](https://github.com/hetznercloud/hcloud-python/commit/0ee759856cb1352f6cc538b7ef86a91cd20380f2)) +- server public ipv4 and ipv6 properties are nullable (#455) +## [2.3.0](https://github.com/hetznercloud/hcloud-python/compare/v2.2.1...v2.3.0) (2024-10-09) -### Dependencies +### Features -* update actions/checkout action to v4 ([#295](https://github.com/hetznercloud/hcloud-python/issues/295)) ([c02b446](https://github.com/hetznercloud/hcloud-python/commit/c02b4468f0e499791bbee8fe48fe7a737985df1f)) -* update dependency sphinx to >=7.2.2,<7.3 ([#291](https://github.com/hetznercloud/hcloud-python/issues/291)) ([10234ea](https://github.com/hetznercloud/hcloud-python/commit/10234ea7bf51a427b18f2b5605d9ffa7ac5f5ee8)) -* update dependency sphinx to v7 ([#211](https://github.com/hetznercloud/hcloud-python/issues/211)) ([f635c94](https://github.com/hetznercloud/hcloud-python/commit/f635c94c23b8ae49283b9b7fcb4fe7b948b203b9)) -* update pre-commit hook asottile/pyupgrade to v3.11.0 ([#298](https://github.com/hetznercloud/hcloud-python/issues/298)) ([4bbd0cc](https://github.com/hetznercloud/hcloud-python/commit/4bbd0ccb0f606e2f90f8242951d3f4d9b86d7aea)) -* update pre-commit hook asottile/pyupgrade to v3.11.1 ([#299](https://github.com/hetznercloud/hcloud-python/issues/299)) ([2f9fcd7](https://github.com/hetznercloud/hcloud-python/commit/2f9fcd7bb80efb8da6eafab0ee70a8dda93eb6f1)) -* update pre-commit hook asottile/pyupgrade to v3.13.0 ([#301](https://github.com/hetznercloud/hcloud-python/issues/301)) ([951dbf3](https://github.com/hetznercloud/hcloud-python/commit/951dbf3e3b3816ffaeb44a583251a5a3a4b90b70)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.3 ([#294](https://github.com/hetznercloud/hcloud-python/issues/294)) ([381e336](https://github.com/hetznercloud/hcloud-python/commit/381e336ff1259fa26cb6abae3b7341cb16229a4b)) -* update pre-commit hook psf/black to v23.9.1 ([#296](https://github.com/hetznercloud/hcloud-python/issues/296)) ([4374a7b](https://github.com/hetznercloud/hcloud-python/commit/4374a7be9f244a72f1fc0c2dd76357cf63f19bfd)) +- support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) +### Bug Fixes -### Documentation +- change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) -* load token from env in examples scripts ([#302](https://github.com/hetznercloud/hcloud-python/issues/302)) ([f18c9a6](https://github.com/hetznercloud/hcloud-python/commit/f18c9a60e045743b26892eeb1fe9e5737a63c11f)) +## [2.2.1](https://github.com/hetznercloud/hcloud-python/compare/v2.2.0...v2.2.1) (2024-08-19) -## [1.28.0](https://github.com/hetznercloud/hcloud-python/compare/v1.27.2...v1.28.0) (2023-08-17) +### Bug Fixes +- prices properties are list of dict ([#438](https://github.com/hetznercloud/hcloud-python/issues/438)) ([9621604](https://github.com/hetznercloud/hcloud-python/commit/96216048c9ba13b6286d584c2dd0ec440f484105)), closes [#437](https://github.com/hetznercloud/hcloud-python/issues/437) + +## [2.2.0](https://github.com/hetznercloud/hcloud-python/compare/v2.1.1...v2.2.0) (2024-08-06) ### Features -* add load balancer target health status field ([#288](https://github.com/hetznercloud/hcloud-python/issues/288)) ([5780418](https://github.com/hetznercloud/hcloud-python/commit/5780418f00a42e20cccacec6e030e464105807ba)) -* implement resource actions clients ([#252](https://github.com/hetznercloud/hcloud-python/issues/252)) ([4bb9a97](https://github.com/hetznercloud/hcloud-python/commit/4bb9a9730eadea9fd0569d5d11b7585dbb5da157)) +- retry requests when the api gateway errors ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the api returns a conflict error ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the network timed outs ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) +- retry requests when the rate limit was reached ([#430](https://github.com/hetznercloud/hcloud-python/issues/430)) ([f63ac8b](https://github.com/hetznercloud/hcloud-python/commit/f63ac8b4d08d84804b5431295ba689275c9203f7)) + +### Bug Fixes +- update network subnet types ([#431](https://github.com/hetznercloud/hcloud-python/issues/431)) ([c32a615](https://github.com/hetznercloud/hcloud-python/commit/c32a615db778d57324632d8df99356bb04a91efa)) -### Dependencies +## [2.1.1](https://github.com/hetznercloud/hcloud-python/compare/v2.1.0...v2.1.1) (2024-07-30) -* update dependency coverage to >=7.3,<7.4 ([#286](https://github.com/hetznercloud/hcloud-python/issues/286)) ([a4df4fa](https://github.com/hetznercloud/hcloud-python/commit/a4df4fa1cc7a17e1afdea1c33f4428a8a594a011)) -* update dependency mypy to >=1.5,<1.6 ([#284](https://github.com/hetznercloud/hcloud-python/issues/284)) ([9dd5c81](https://github.com/hetznercloud/hcloud-python/commit/9dd5c8110bf679c13e8e6ba08e760019b4dae706)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.2 ([#287](https://github.com/hetznercloud/hcloud-python/issues/287)) ([6bf03cb](https://github.com/hetznercloud/hcloud-python/commit/6bf03cb9ab1203f172e1634d28a99a7cb3210ad0)) +### Bug Fixes +- do not sleep before checking for the reloaded action status ([#426](https://github.com/hetznercloud/hcloud-python/issues/426)) ([3e0a85b](https://github.com/hetznercloud/hcloud-python/commit/3e0a85b487fc15941008e4d610243de3cb0396cb)) +- mark client retry backoff function as static ([#429](https://github.com/hetznercloud/hcloud-python/issues/429)) ([14ed130](https://github.com/hetznercloud/hcloud-python/commit/14ed130e989c68eacce2634c7983b200570de9c2)) ### Documentation -* fail on warning ([#289](https://github.com/hetznercloud/hcloud-python/issues/289)) ([e61300e](https://github.com/hetznercloud/hcloud-python/commit/e61300eda7f0ba15e0a91cce3e4b8f7542ed42c8)) +- add api changes note in changelog ([#424](https://github.com/hetznercloud/hcloud-python/issues/424)) ([5cbe188](https://github.com/hetznercloud/hcloud-python/commit/5cbe1889a21c686588d91ab90306d345ba5b84dd)) -## [1.27.2](https://github.com/hetznercloud/hcloud-python/compare/v1.27.1...v1.27.2) (2023-08-09) +## [2.1.0](https://github.com/hetznercloud/hcloud-python/compare/v2.0.1...v2.1.0) (2024-07-25) +### API Changes for Traffic Prices and Server Type Included Traffic -### Documentation +There will be a breaking change in the API regarding Traffic Prices and Server Type Included Traffic on 2024-08-05. This release marks the affected fields as `Deprecated`. Please check if this affects any of your code and switch to the replacement fields where necessary. -* fix python references ([#281](https://github.com/hetznercloud/hcloud-python/issues/281)) ([0c0518e](https://github.com/hetznercloud/hcloud-python/commit/0c0518e38e8c6ebe280ee85259480fb5671c2d84)) +You can learn more about this change in [our changelog](https://docs.hetzner.cloud/changelog#2024-07-25-cloud-api-returns-traffic-information-in-different-format). -## [1.27.1](https://github.com/hetznercloud/hcloud-python/compare/v1.27.0...v1.27.1) (2023-08-08) +### Features + +- add exponential and constant backoff function ([#416](https://github.com/hetznercloud/hcloud-python/issues/416)) ([fe7ddf6](https://github.com/hetznercloud/hcloud-python/commit/fe7ddf6da78f8dbbc395eb98ff1200b8117f0cc0)) +- deprecate `ServerType` `included_traffic` property ([#423](https://github.com/hetznercloud/hcloud-python/issues/423)) ([3d56ac5](https://github.com/hetznercloud/hcloud-python/commit/3d56ac57d092bb30543fac9249c04393d0864c3b)) +- use exponential backoff when retrying requests ([#417](https://github.com/hetznercloud/hcloud-python/issues/417)) ([f306073](https://github.com/hetznercloud/hcloud-python/commit/f3060737d0e2991a0abf69e4953a3967ac8f84ed)) +## [2.0.1](https://github.com/hetznercloud/hcloud-python/compare/v2.0.0...v2.0.1) (2024-07-03) ### Bug Fixes -* missing long_description content_type in setup.py ([#279](https://github.com/hetznercloud/hcloud-python/issues/279)) ([6d79d1d](https://github.com/hetznercloud/hcloud-python/commit/6d79d1d18d3731c3db70184c841428e9c4b2a32c)) +- `assignee_type` is required when creating a primary ip ([#409](https://github.com/hetznercloud/hcloud-python/issues/409)) ([bce5e94](https://github.com/hetznercloud/hcloud-python/commit/bce5e940e27f2c6d9d50016b5828c79aadfc4401)) +- clean unused arguments in the `Client.servers.rebuild` method ([#407](https://github.com/hetznercloud/hcloud-python/issues/407)) ([6d33c3c](https://github.com/hetznercloud/hcloud-python/commit/6d33c3cff5443686c7ed37eb8635e0461bb3b928)) +- details are optional in API errors ([#411](https://github.com/hetznercloud/hcloud-python/issues/411)) ([f1c6594](https://github.com/hetznercloud/hcloud-python/commit/f1c6594dee7088872f2375359ee259e4e93b31d2)) +- rename `trace_id` variable to `correlation_id` ([#408](https://github.com/hetznercloud/hcloud-python/issues/408)) ([66a0f54](https://github.com/hetznercloud/hcloud-python/commit/66a0f546998193f9078f70a4a2fb1fc11937c086)) -## [1.27.0](https://github.com/hetznercloud/hcloud-python/compare/v1.26.0...v1.27.0) (2023-08-08) +## [2.0.0](https://github.com/hetznercloud/hcloud-python/compare/v1.35.0...v2.0.0) (2024-07-03) + +### ⚠ BREAKING CHANGES +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) +- make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ### Features -* add global request timeout option ([#271](https://github.com/hetznercloud/hcloud-python/issues/271)) ([07a663f](https://github.com/hetznercloud/hcloud-python/commit/07a663fd8628d305a7461a90a94c61a97c12421b)) -* reexport references in parent ressources modules ([#256](https://github.com/hetznercloud/hcloud-python/issues/256)) ([854c12b](https://github.com/hetznercloud/hcloud-python/commit/854c12bbde3a5f0dcc77cabe72ecab2fd72fbac0)) -* the package is now typed ([#265](https://github.com/hetznercloud/hcloud-python/issues/265)) ([da8baa5](https://github.com/hetznercloud/hcloud-python/commit/da8baa551628fb759c790871362fef1e3666c56b)) +- add `trace_id` to API exceptions ([#404](https://github.com/hetznercloud/hcloud-python/issues/404)) ([8375261](https://github.com/hetznercloud/hcloud-python/commit/8375261da3b84d6fece97263c7bea40ad2a6cfcf)) +- allow using a custom poll_interval function ([#403](https://github.com/hetznercloud/hcloud-python/issues/403)) ([93eb56b](https://github.com/hetznercloud/hcloud-python/commit/93eb56ba4d1a69e175398bca42e723a7e8e46371)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) ([d5f24db](https://github.com/hetznercloud/hcloud-python/commit/d5f24db2816a0d00b8c7936e2a0290d2c4bb1e92)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) ([428ea7e](https://github.com/hetznercloud/hcloud-python/commit/428ea7e3be03a16114f875146971db59aabaac2c)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ([4e3f638](https://github.com/hetznercloud/hcloud-python/commit/4e3f638862c9d260df98182c3f7858282049c26c)), closes [#234](https://github.com/hetznercloud/hcloud-python/issues/234) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) ([db37e63](https://github.com/hetznercloud/hcloud-python/commit/db37e633ebbf73354d3b2f4858cf3eebf173bfbc)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) ([6b977e2](https://github.com/hetznercloud/hcloud-python/commit/6b977e2da5cec30110c32a91d572003e5b5c400a)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) ([9f46adb](https://github.com/hetznercloud/hcloud-python/commit/9f46adb946eb2770ee4f3a4e87cfc1c8b9b33c28)) +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) ([1970d84](https://github.com/hetznercloud/hcloud-python/commit/1970d84bec2106c8c53d8e611b74d41eb5286e9b)) + +### Bug Fixes + +- make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) ([ebef774](https://github.com/hetznercloud/hcloud-python/commit/ebef77464c4c3b0ce33460cad2747e89d35047c7)) + +### Dependencies + +- update dependency coverage to >=7.5,<7.6 ([#386](https://github.com/hetznercloud/hcloud-python/issues/386)) ([5660691](https://github.com/hetznercloud/hcloud-python/commit/5660691ebd6122fa7ebec56a24bce9fce0577573)) +- update dependency mypy to >=1.10,<1.11 ([#387](https://github.com/hetznercloud/hcloud-python/issues/387)) ([35c933b](https://github.com/hetznercloud/hcloud-python/commit/35c933bd2108d42e74b74b01d6db74e159ec9142)) +- update dependency myst-parser to v3 ([#385](https://github.com/hetznercloud/hcloud-python/issues/385)) ([9f18270](https://github.com/hetznercloud/hcloud-python/commit/9f182704898cb96f1ea162511605906f87cff50c)) +- update dependency pylint to >=3,<3.3 ([#391](https://github.com/hetznercloud/hcloud-python/issues/391)) ([4a6f005](https://github.com/hetznercloud/hcloud-python/commit/4a6f005cb0488291ae91390a612bab6afc6d80b6)) +- update dependency pytest to >=8,<8.3 ([#390](https://github.com/hetznercloud/hcloud-python/issues/390)) ([584a36b](https://github.com/hetznercloud/hcloud-python/commit/584a36b658670297ffffa9afa70835d29d27fbca)) +- update dependency sphinx to >=7.3.4,<7.4 ([#383](https://github.com/hetznercloud/hcloud-python/issues/383)) ([69c2e16](https://github.com/hetznercloud/hcloud-python/commit/69c2e16073df9ef8520e3a635b3866403eba030e)) +- update pre-commit hook asottile/pyupgrade to v3.16.0 ([0ce5fbc](https://github.com/hetznercloud/hcloud-python/commit/0ce5fbccba4a4255e08a37abf1f21ab9cc85f287)) +- update pre-commit hook pre-commit/pre-commit-hooks to v4.6.0 ([5ef25ab](https://github.com/hetznercloud/hcloud-python/commit/5ef25ab3966d731c4c36ea3e785c2b5f20c69489)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.0 ([0941fbf](https://github.com/hetznercloud/hcloud-python/commit/0941fbfab20ca8a59e768c4a5e6fc101393c97f0)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.1 ([fec08c5](https://github.com/hetznercloud/hcloud-python/commit/fec08c5323359d0a4f0771123f483ff975aa68b0)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.4.2 ([#389](https://github.com/hetznercloud/hcloud-python/issues/389)) ([2b2e21f](https://github.com/hetznercloud/hcloud-python/commit/2b2e21f61366b5ec0f2ff5558f652d2bfed9d138)) +- update pre-commit hook pycqa/flake8 to v7.1.0 ([3bc651d](https://github.com/hetznercloud/hcloud-python/commit/3bc651d50d85aa92ba76dbfeef1d604cabaa4628)) + +### Documentation + +- add v2 upgrade notes ([#405](https://github.com/hetznercloud/hcloud-python/issues/405)) ([c77f771](https://github.com/hetznercloud/hcloud-python/commit/c77f771e2bed176acd6aa5011be006c800181809)) +- cx11 is name, not an id ([#381](https://github.com/hetznercloud/hcloud-python/issues/381)) ([b745d40](https://github.com/hetznercloud/hcloud-python/commit/b745d4049f720b93d840a9204a99d246ecb499e5)) +## [1.35.0](https://github.com/hetznercloud/hcloud-python/compare/v1.34.0...v1.35.0) (2024-04-02) + +### Features + +- add `include_deprecated` option when fetching images by name ([#375](https://github.com/hetznercloud/hcloud-python/issues/375)) ([6d86f86](https://github.com/hetznercloud/hcloud-python/commit/6d86f86677fec23e6fd8a69d20d787e234e0fb53)) ### Bug Fixes -* allow omitting `datacenter` when creating a primary ip ([#171](https://github.com/hetznercloud/hcloud-python/issues/171)) ([4375dc6](https://github.com/hetznercloud/hcloud-python/commit/4375dc6ec351207380a011ec35e1397bf2bd17e9)) -* ineffective doc strings ([#266](https://github.com/hetznercloud/hcloud-python/issues/266)) ([bb34df9](https://github.com/hetznercloud/hcloud-python/commit/bb34df9390030e70f39bb82c92f4040eef18eb3b)) -* invalid attribute in placement group ([#258](https://github.com/hetznercloud/hcloud-python/issues/258)) ([23b3607](https://github.com/hetznercloud/hcloud-python/commit/23b36079d997d28d73cb9edc9a51a8c3b4481d7e)) +- raise warnings for the `ImagesClient.get_by_name` deprecation ([#376](https://github.com/hetznercloud/hcloud-python/issues/376)) ([b24de80](https://github.com/hetznercloud/hcloud-python/commit/b24de80684db142ebbe11b62a38d9c61f248e216)) + +## [1.34.0](https://github.com/hetznercloud/hcloud-python/compare/v1.33.3...v1.34.0) (2024-03-27) + +### Features + +- add `has_id_or_name` to `DomainIdentityMixin` ([#373](https://github.com/hetznercloud/hcloud-python/issues/373)) ([8facaf6](https://github.com/hetznercloud/hcloud-python/commit/8facaf6d4dd2bbfb4137e7066b49c5f4c1db773c)) + +## [1.33.3](https://github.com/hetznercloud/hcloud-python/compare/v1.33.2...v1.33.3) (2024-03-27) + +### Bug Fixes +- invalid type for load balancer private network property ([#372](https://github.com/hetznercloud/hcloud-python/issues/372)) ([903e92f](https://github.com/hetznercloud/hcloud-python/commit/903e92faab745b7f8270f6195da67f4d9f8b1ba7)) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.10.1 ([#261](https://github.com/hetznercloud/hcloud-python/issues/261)) ([efa5780](https://github.com/hetznercloud/hcloud-python/commit/efa5780d0de3080bffe43994c064a0f1bcf6da43)) -* update pre-commit hook pre-commit/mirrors-prettier to v3.0.1 ([#269](https://github.com/hetznercloud/hcloud-python/issues/269)) ([2239b0b](https://github.com/hetznercloud/hcloud-python/commit/2239b0bc9beae457215c6514b0b823cc84a4a463)) -* update pre-commit hook pycqa/flake8 to v6.1.0 ([#260](https://github.com/hetznercloud/hcloud-python/issues/260)) ([fd01384](https://github.com/hetznercloud/hcloud-python/commit/fd013842f7f94e98520ed403a8cd91b68a4c4e5c)) +- update codecov/codecov-action action to v4 ([#359](https://github.com/hetznercloud/hcloud-python/issues/359)) ([a798979](https://github.com/hetznercloud/hcloud-python/commit/a79897977abe970181d19584e51448ff5976b5e2)) +- update dependency mypy to >=1.9,<1.10 ([#368](https://github.com/hetznercloud/hcloud-python/issues/368)) ([4b9328c](https://github.com/hetznercloud/hcloud-python/commit/4b9328ceae1e393ff55b3ca6f030cb5ac565be00)) +- update dependency pylint to >=3,<3.2 ([#364](https://github.com/hetznercloud/hcloud-python/issues/364)) ([d71d17f](https://github.com/hetznercloud/hcloud-python/commit/d71d17fd6f2968a8c19052753265ef7f514a8955)) +- update dependency pytest to >=8,<8.2 ([#366](https://github.com/hetznercloud/hcloud-python/issues/366)) ([8665dcf](https://github.com/hetznercloud/hcloud-python/commit/8665dcff335c755c1ff4d95621334a3f5e196d34)) +- update dependency pytest to v8 ([#357](https://github.com/hetznercloud/hcloud-python/issues/357)) ([f8f756f](https://github.com/hetznercloud/hcloud-python/commit/f8f756fe0a492e284bd2a700514c0ba38358b4a8)) +- update dependency pytest-cov to v5 ([#371](https://github.com/hetznercloud/hcloud-python/issues/371)) ([04a6a42](https://github.com/hetznercloud/hcloud-python/commit/04a6a42028606ed66657605d98b1f21545eb2e0d)) +- update dependency watchdog to v4 ([#360](https://github.com/hetznercloud/hcloud-python/issues/360)) ([cb8d383](https://github.com/hetznercloud/hcloud-python/commit/cb8d38396a8665506e3be64a09450343d7671586)) +- update pre-commit hook asottile/pyupgrade to v3.15.1 ([#362](https://github.com/hetznercloud/hcloud-python/issues/362)) ([dd2a521](https://github.com/hetznercloud/hcloud-python/commit/dd2a521eccec8e15b6d1d7fd843d866bf6ea5bcf)) +- update pre-commit hook asottile/pyupgrade to v3.15.2 ([3d02ad7](https://github.com/hetznercloud/hcloud-python/commit/3d02ad71e9200f5cc94b2d33eea62035edc1e33a)) +- update pre-commit hook psf/black-pre-commit-mirror to v24 ([#356](https://github.com/hetznercloud/hcloud-python/issues/356)) ([b46397d](https://github.com/hetznercloud/hcloud-python/commit/b46397d761caa60014bd32f7142b79bef9a92e18)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.1.1 ([#358](https://github.com/hetznercloud/hcloud-python/issues/358)) ([7e4645e](https://github.com/hetznercloud/hcloud-python/commit/7e4645e3e38a106f38a7f63810d71a628fead939)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.2.0 ([#361](https://github.com/hetznercloud/hcloud-python/issues/361)) ([5b56ace](https://github.com/hetznercloud/hcloud-python/commit/5b56ace93b8b4fddddbf5610c11fd20bf6f9a561)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.3.0 ([3bbac5d](https://github.com/hetznercloud/hcloud-python/commit/3bbac5dc41ca509d6679fd6b06ae99ca33fd62ee)) +- update pre-commit hook pycqa/flake8 to v7 ([#354](https://github.com/hetznercloud/hcloud-python/issues/354)) ([66a582f](https://github.com/hetznercloud/hcloud-python/commit/66a582f3ce728d92045625885d0634fc96fbc6a0)) +- update pypa/gh-action-pypi-publish action to v1.8.12 ([#365](https://github.com/hetznercloud/hcloud-python/issues/365)) ([55db255](https://github.com/hetznercloud/hcloud-python/commit/55db2551dd0f0ea6a29da4e7a6dce2af8de86eaf)) +- update pypa/gh-action-pypi-publish action to v1.8.14 ([#367](https://github.com/hetznercloud/hcloud-python/issues/367)) ([0cb615f](https://github.com/hetznercloud/hcloud-python/commit/0cb615fe0d852cddbf636c1fdb8538ad60f5a3d9)) +## [1.33.2](https://github.com/hetznercloud/hcloud-python/compare/v1.33.1...v1.33.2) (2024-01-02) -### Documentation +### Bug Fixes -* update documentation ([#247](https://github.com/hetznercloud/hcloud-python/issues/247)) ([e63741f](https://github.com/hetznercloud/hcloud-python/commit/e63741fab50524f4e4098af5c77f806915ae93c8)) -* update hetzner logo ([#264](https://github.com/hetznercloud/hcloud-python/issues/264)) ([ee79851](https://github.com/hetznercloud/hcloud-python/commit/ee79851dbf00e50d7f6b398fd4323f3e14831831)) +- publish package to PyPI using OIDC auth ([1a0e93b](https://github.com/hetznercloud/hcloud-python/commit/1a0e93bbf1ae6cc747e6c4d8305dafd3e49dbbdc)) -## [1.26.0](https://github.com/hetznercloud/hcloud-python/compare/v1.25.0...v1.26.0) (2023-07-19) +## [1.33.1](https://github.com/hetznercloud/hcloud-python/compare/v1.33.0...v1.33.1) (2024-01-02) +### Bug Fixes -### Features +- private object not exported in top level module ([#346](https://github.com/hetznercloud/hcloud-python/issues/346)) ([5281b05](https://github.com/hetznercloud/hcloud-python/commit/5281b0583541b6e0e9b8c7ad75faa42c5d379735)) -* add __repr__ method to domains ([#246](https://github.com/hetznercloud/hcloud-python/issues/246)) ([4c22765](https://github.com/hetznercloud/hcloud-python/commit/4c227659bfb61551e44c41315b135039576960d3)) -* drop support for python 3.7 ([#242](https://github.com/hetznercloud/hcloud-python/issues/242)) ([2ce71e9](https://github.com/hetznercloud/hcloud-python/commit/2ce71e9ded5e9bb87ce96519ce59db942f4f9670)) +### Dependencies -## [1.25.0](https://github.com/hetznercloud/hcloud-python/compare/v1.24.0...v1.25.0) (2023-07-14) +- update dependency coverage to >=7.4,<7.5 ([#348](https://github.com/hetznercloud/hcloud-python/issues/348)) ([3ac5711](https://github.com/hetznercloud/hcloud-python/commit/3ac57117e8a68a02cba19c56f850f037c4aca462)) +- update dependency mypy to >=1.8,<1.9 ([#343](https://github.com/hetznercloud/hcloud-python/issues/343)) ([984022f](https://github.com/hetznercloud/hcloud-python/commit/984022fd3888ef856be83de82554d55a8af18dba)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.1 ([#347](https://github.com/hetznercloud/hcloud-python/issues/347)) ([2c24efe](https://github.com/hetznercloud/hcloud-python/commit/2c24efe93bc221846f8dcc91abcf1aad61547875)) +## [1.33.0](https://github.com/hetznercloud/hcloud-python/compare/v1.32.0...v1.33.0) (2023-12-19) ### Features -* add details to raise exceptions ([#240](https://github.com/hetznercloud/hcloud-python/issues/240)) ([cf64e54](https://github.com/hetznercloud/hcloud-python/commit/cf64e549a2b28aea91062dea67db8733b4ecdd6f)) -* move hcloud.hcloud module to hcloud._client ([#243](https://github.com/hetznercloud/hcloud-python/issues/243)) ([413472d](https://github.com/hetznercloud/hcloud-python/commit/413472d7af1602b872a9b56324b9bffd0067eee6)) +- add metrics endpoint for load balancers and servers ([#331](https://github.com/hetznercloud/hcloud-python/issues/331)) ([ee3c54f](https://github.com/hetznercloud/hcloud-python/commit/ee3c54fd1b6963533bc9d1e1f9ff57f6c5872cd5)) + +### Bug Fixes +- fallback to error code when message is unset ([#328](https://github.com/hetznercloud/hcloud-python/issues/328)) ([1c94153](https://github.com/hetznercloud/hcloud-python/commit/1c94153d93acd567548604b08b5fabeabd8d33d9)) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.9.0 ([#238](https://github.com/hetznercloud/hcloud-python/issues/238)) ([0053ded](https://github.com/hetznercloud/hcloud-python/commit/0053ded5a1d0c2407134706830dd8ff3d4d1e8ce)) -* update pre-commit hook pre-commit/mirrors-prettier to v3 ([#235](https://github.com/hetznercloud/hcloud-python/issues/235)) ([047d4e1](https://github.com/hetznercloud/hcloud-python/commit/047d4e173a53e91252d57d01b2e95def1c4949d9)) -* update pre-commit hook psf/black to v23.7.0 ([#239](https://github.com/hetznercloud/hcloud-python/issues/239)) ([443bf26](https://github.com/hetznercloud/hcloud-python/commit/443bf262cb524dd674d2007db8100fec94dab80d)) +- update actions/setup-python action to v5 ([#335](https://github.com/hetznercloud/hcloud-python/issues/335)) ([2ac252d](https://github.com/hetznercloud/hcloud-python/commit/2ac252d18ba6079d5372c6ab9e3f67b4740db465)) +- update dependency sphinx-rtd-theme to v2 ([#330](https://github.com/hetznercloud/hcloud-python/issues/330)) ([7cc4335](https://github.com/hetznercloud/hcloud-python/commit/7cc4335cacab6073cf39a0ecbecf8890903d5bca)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.0 ([#338](https://github.com/hetznercloud/hcloud-python/issues/338)) ([38e4748](https://github.com/hetznercloud/hcloud-python/commit/38e4748d3d194d37ea3d0c63683609f5db432e0d)) +- update pre-commit hook pycqa/isort to v5.13.0 ([#336](https://github.com/hetznercloud/hcloud-python/issues/336)) ([3244cfe](https://github.com/hetznercloud/hcloud-python/commit/3244cfef2f90ef52d0fb791d514d6afe481aa4d7)) +- update pre-commit hook pycqa/isort to v5.13.1 ([#337](https://github.com/hetznercloud/hcloud-python/issues/337)) ([020a0ef](https://github.com/hetznercloud/hcloud-python/commit/020a0eff6bc2b63d16b339fd5d4c3ea3610c0509)) +- update pre-commit hook pycqa/isort to v5.13.2 ([#339](https://github.com/hetznercloud/hcloud-python/issues/339)) ([b46df8c](https://github.com/hetznercloud/hcloud-python/commit/b46df8cbb263945c59ce4408e0a7189d19d9c597)) -## [1.24.0](https://github.com/hetznercloud/hcloud-python/compare/v1.23.1...v1.24.0) (2023-07-03) +## [1.32.0](https://github.com/hetznercloud/hcloud-python/compare/v1.31.0...v1.32.0) (2023-11-17) + +### Features + +- allow returning root_password in servers rebuild ([#276](https://github.com/hetznercloud/hcloud-python/issues/276)) ([38e098a](https://github.com/hetznercloud/hcloud-python/commit/38e098a41154e6561578cd723608fcd7577c3d01)) + +### Dependencies +- update dependency mypy to >=1.7,<1.8 ([#325](https://github.com/hetznercloud/hcloud-python/issues/325)) ([7b59a2d](https://github.com/hetznercloud/hcloud-python/commit/7b59a2decc9bb5152dc9de435bfe12ce1f34ac1c)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.1.0 ([#326](https://github.com/hetznercloud/hcloud-python/issues/326)) ([213b661](https://github.com/hetznercloud/hcloud-python/commit/213b661d897cdd327f478b52aeb79844826694d8)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.10.1 ([#322](https://github.com/hetznercloud/hcloud-python/issues/322)) ([999afe3](https://github.com/hetznercloud/hcloud-python/commit/999afe37e02a113639930aff6879f50918ac0e89)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.11.0 ([#324](https://github.com/hetznercloud/hcloud-python/issues/324)) ([7b2a24e](https://github.com/hetznercloud/hcloud-python/commit/7b2a24ecf69c0bead7f9113053fda37a0cc31d1b)) + +## [1.31.0](https://github.com/hetznercloud/hcloud-python/compare/v1.30.0...v1.31.0) (2023-10-23) ### Features -* revert remove python-dateutil dependency ([#231](https://github.com/hetznercloud/hcloud-python/issues/231)) ([945bfde](https://github.com/hetznercloud/hcloud-python/commit/945bfde2ff0f64896e5c4d017e69236913e9d9dd)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) +- prepare for iso deprecated field removal ([#320](https://github.com/hetznercloud/hcloud-python/issues/320)) ([beae328](https://github.com/hetznercloud/hcloud-python/commit/beae328dd6b9afb8c0db9fa9b44340270db7dd09)) + +### Dependencies + +- update pre-commit hook psf/black-pre-commit-mirror to v23.10.0 ([#319](https://github.com/hetznercloud/hcloud-python/issues/319)) ([184bbe6](https://github.com/hetznercloud/hcloud-python/commit/184bbe65a736a42d13774b6c29fa7dd8a13ec645)) + +## [1.30.0](https://github.com/hetznercloud/hcloud-python/compare/v1.29.1...v1.30.0) (2023-10-13) + +### Features +- add deprecation field to Iso ([#318](https://github.com/hetznercloud/hcloud-python/issues/318)) ([036b52f](https://github.com/hetznercloud/hcloud-python/commit/036b52fe51bcbb6b610c0c99ca224d3c4bbfc68d)) +- support python 3.12 ([#311](https://github.com/hetznercloud/hcloud-python/issues/311)) ([7e8cd1d](https://github.com/hetznercloud/hcloud-python/commit/7e8cd1d92e56d210fe3fb180e403122ef0e7bd7f)) ### Dependencies -* update pre-commit hook asottile/pyupgrade to v3.8.0 ([#232](https://github.com/hetznercloud/hcloud-python/issues/232)) ([27f21bc](https://github.com/hetznercloud/hcloud-python/commit/27f21bc41e17a800a8a3bed1df7935e7fb31de42)) +- update dependency mypy to >=1.6,<1.7 ([#317](https://github.com/hetznercloud/hcloud-python/issues/317)) ([d248bbd](https://github.com/hetznercloud/hcloud-python/commit/d248bbd4e55f3bcf6a107cfa4f38768df0bf3de5)) +- update dependency pylint to v3 ([#307](https://github.com/hetznercloud/hcloud-python/issues/307)) ([277841d](https://github.com/hetznercloud/hcloud-python/commit/277841dd84ba3b2bbc99a06a3f97e114d1c83dcb)) +- update pre-commit hook asottile/pyupgrade to v3.14.0 ([#308](https://github.com/hetznercloud/hcloud-python/issues/308)) ([07a4513](https://github.com/hetznercloud/hcloud-python/commit/07a4513e284b9ee964bca003d0a9dfd948d39b02)) +- update pre-commit hook asottile/pyupgrade to v3.15.0 ([#312](https://github.com/hetznercloud/hcloud-python/issues/312)) ([c544639](https://github.com/hetznercloud/hcloud-python/commit/c5446394acfa25d23761da4c6b5b75fb6d376b23)) +- update pre-commit hook pre-commit/pre-commit-hooks to v4.5.0 ([#313](https://github.com/hetznercloud/hcloud-python/issues/313)) ([e51eaa9](https://github.com/hetznercloud/hcloud-python/commit/e51eaa990336251c2afc8c83d4c5e6f5e5bb857b)) +- update python docker tag to v3.12 ([#309](https://github.com/hetznercloud/hcloud-python/issues/309)) ([3a1ee67](https://github.com/hetznercloud/hcloud-python/commit/3a1ee675f2c980a4d9e63188e8ffceb64f4797fc)) -## [1.23.1](https://github.com/hetznercloud/hcloud-python/compare/v1.23.0...v1.23.1) (2023-06-30) +## [1.29.1](https://github.com/hetznercloud/hcloud-python/compare/v1.29.0...v1.29.1) (2023-09-26) + +### Bug Fixes + +- prevent api calls when printing bound models ([#305](https://github.com/hetznercloud/hcloud-python/issues/305)) ([c1de7ef](https://github.com/hetznercloud/hcloud-python/commit/c1de7efc851b3b10e2a50e66268fc8fb0ff648a8)) + +## [1.29.0](https://github.com/hetznercloud/hcloud-python/compare/v1.28.0...v1.29.0) (2023-09-25) +### Features + +- add domain attribute type hints to bound models ([#300](https://github.com/hetznercloud/hcloud-python/issues/300)) ([6d46d06](https://github.com/hetznercloud/hcloud-python/commit/6d46d06c42e2e86e88b32a74d7fbd588911cc8ad)) +- **firewalls:** add `applied_to_resources` to `FirewallResource` ([#297](https://github.com/hetznercloud/hcloud-python/issues/297)) ([55d2b20](https://github.com/hetznercloud/hcloud-python/commit/55d2b2043ec1e3a040eb9e360ca0dc0c299ad60f)) ### Bug Fixes -* handle Z timezone in ISO8601 datetime format ([#228](https://github.com/hetznercloud/hcloud-python/issues/228)) ([6a5c3f4](https://github.com/hetznercloud/hcloud-python/commit/6a5c3f42c092610c4a82cb79c0052499563549dc)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) +- missing BaseDomain base class inheritance ([#303](https://github.com/hetznercloud/hcloud-python/issues/303)) ([0ee7598](https://github.com/hetznercloud/hcloud-python/commit/0ee759856cb1352f6cc538b7ef86a91cd20380f2)) -## [1.23.0](https://github.com/hetznercloud/hcloud-python/compare/v1.22.0...v1.23.0) (2023-06-26) +### Dependencies +- update actions/checkout action to v4 ([#295](https://github.com/hetznercloud/hcloud-python/issues/295)) ([c02b446](https://github.com/hetznercloud/hcloud-python/commit/c02b4468f0e499791bbee8fe48fe7a737985df1f)) +- update dependency sphinx to >=7.2.2,<7.3 ([#291](https://github.com/hetznercloud/hcloud-python/issues/291)) ([10234ea](https://github.com/hetznercloud/hcloud-python/commit/10234ea7bf51a427b18f2b5605d9ffa7ac5f5ee8)) +- update dependency sphinx to v7 ([#211](https://github.com/hetznercloud/hcloud-python/issues/211)) ([f635c94](https://github.com/hetznercloud/hcloud-python/commit/f635c94c23b8ae49283b9b7fcb4fe7b948b203b9)) +- update pre-commit hook asottile/pyupgrade to v3.11.0 ([#298](https://github.com/hetznercloud/hcloud-python/issues/298)) ([4bbd0cc](https://github.com/hetznercloud/hcloud-python/commit/4bbd0ccb0f606e2f90f8242951d3f4d9b86d7aea)) +- update pre-commit hook asottile/pyupgrade to v3.11.1 ([#299](https://github.com/hetznercloud/hcloud-python/issues/299)) ([2f9fcd7](https://github.com/hetznercloud/hcloud-python/commit/2f9fcd7bb80efb8da6eafab0ee70a8dda93eb6f1)) +- update pre-commit hook asottile/pyupgrade to v3.13.0 ([#301](https://github.com/hetznercloud/hcloud-python/issues/301)) ([951dbf3](https://github.com/hetznercloud/hcloud-python/commit/951dbf3e3b3816ffaeb44a583251a5a3a4b90b70)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.3 ([#294](https://github.com/hetznercloud/hcloud-python/issues/294)) ([381e336](https://github.com/hetznercloud/hcloud-python/commit/381e336ff1259fa26cb6abae3b7341cb16229a4b)) +- update pre-commit hook psf/black to v23.9.1 ([#296](https://github.com/hetznercloud/hcloud-python/issues/296)) ([4374a7b](https://github.com/hetznercloud/hcloud-python/commit/4374a7be9f244a72f1fc0c2dd76357cf63f19bfd)) + +### Documentation + +- load token from env in examples scripts ([#302](https://github.com/hetznercloud/hcloud-python/issues/302)) ([f18c9a6](https://github.com/hetznercloud/hcloud-python/commit/f18c9a60e045743b26892eeb1fe9e5737a63c11f)) + +## [1.28.0](https://github.com/hetznercloud/hcloud-python/compare/v1.27.2...v1.28.0) (2023-08-17) ### Features -* remove python-dateutil dependency ([#221](https://github.com/hetznercloud/hcloud-python/issues/221)) ([8ea4aa0](https://github.com/hetznercloud/hcloud-python/commit/8ea4aa0ad12e85eeb14c81dfa2195e1a6ee79a76)) +- add load balancer target health status field ([#288](https://github.com/hetznercloud/hcloud-python/issues/288)) ([5780418](https://github.com/hetznercloud/hcloud-python/commit/5780418f00a42e20cccacec6e030e464105807ba)) +- implement resource actions clients ([#252](https://github.com/hetznercloud/hcloud-python/issues/252)) ([4bb9a97](https://github.com/hetznercloud/hcloud-python/commit/4bb9a9730eadea9fd0569d5d11b7585dbb5da157)) + +### Dependencies + +- update dependency coverage to >=7.3,<7.4 ([#286](https://github.com/hetznercloud/hcloud-python/issues/286)) ([a4df4fa](https://github.com/hetznercloud/hcloud-python/commit/a4df4fa1cc7a17e1afdea1c33f4428a8a594a011)) +- update dependency mypy to >=1.5,<1.6 ([#284](https://github.com/hetznercloud/hcloud-python/issues/284)) ([9dd5c81](https://github.com/hetznercloud/hcloud-python/commit/9dd5c8110bf679c13e8e6ba08e760019b4dae706)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.2 ([#287](https://github.com/hetznercloud/hcloud-python/issues/287)) ([6bf03cb](https://github.com/hetznercloud/hcloud-python/commit/6bf03cb9ab1203f172e1634d28a99a7cb3210ad0)) + +### Documentation + +- fail on warning ([#289](https://github.com/hetznercloud/hcloud-python/issues/289)) ([e61300e](https://github.com/hetznercloud/hcloud-python/commit/e61300eda7f0ba15e0a91cce3e4b8f7542ed42c8)) + +## [1.27.2](https://github.com/hetznercloud/hcloud-python/compare/v1.27.1...v1.27.2) (2023-08-09) + +### Documentation + +- fix python references ([#281](https://github.com/hetznercloud/hcloud-python/issues/281)) ([0c0518e](https://github.com/hetznercloud/hcloud-python/commit/0c0518e38e8c6ebe280ee85259480fb5671c2d84)) +## [1.27.1](https://github.com/hetznercloud/hcloud-python/compare/v1.27.0...v1.27.1) (2023-08-08) ### Bug Fixes -* **isos:** invalid name for include_wildcard_architecture argument ([#222](https://github.com/hetznercloud/hcloud-python/issues/222)) ([c3dfcab](https://github.com/hetznercloud/hcloud-python/commit/c3dfcaba44d88fcf6913a6e68caee2afde06e551)) +- missing long_description content_type in setup.py ([#279](https://github.com/hetznercloud/hcloud-python/issues/279)) ([6d79d1d](https://github.com/hetznercloud/hcloud-python/commit/6d79d1d18d3731c3db70184c841428e9c4b2a32c)) + +## [1.27.0](https://github.com/hetznercloud/hcloud-python/compare/v1.26.0...v1.27.0) (2023-08-08) + +### Features + +- add global request timeout option ([#271](https://github.com/hetznercloud/hcloud-python/issues/271)) ([07a663f](https://github.com/hetznercloud/hcloud-python/commit/07a663fd8628d305a7461a90a94c61a97c12421b)) +- reexport references in parent ressources modules ([#256](https://github.com/hetznercloud/hcloud-python/issues/256)) ([854c12b](https://github.com/hetznercloud/hcloud-python/commit/854c12bbde3a5f0dcc77cabe72ecab2fd72fbac0)) +- the package is now typed ([#265](https://github.com/hetznercloud/hcloud-python/issues/265)) ([da8baa5](https://github.com/hetznercloud/hcloud-python/commit/da8baa551628fb759c790871362fef1e3666c56b)) + +### Bug Fixes +- allow omitting `datacenter` when creating a primary ip ([#171](https://github.com/hetznercloud/hcloud-python/issues/171)) ([4375dc6](https://github.com/hetznercloud/hcloud-python/commit/4375dc6ec351207380a011ec35e1397bf2bd17e9)) +- ineffective doc strings ([#266](https://github.com/hetznercloud/hcloud-python/issues/266)) ([bb34df9](https://github.com/hetznercloud/hcloud-python/commit/bb34df9390030e70f39bb82c92f4040eef18eb3b)) +- invalid attribute in placement group ([#258](https://github.com/hetznercloud/hcloud-python/issues/258)) ([23b3607](https://github.com/hetznercloud/hcloud-python/commit/23b36079d997d28d73cb9edc9a51a8c3b4481d7e)) ### Dependencies -* update dependency pytest to >=7.4,<7.5 ([#217](https://github.com/hetznercloud/hcloud-python/issues/217)) ([11e1f45](https://github.com/hetznercloud/hcloud-python/commit/11e1f455611b17a22328b3422d0b800552ea91e3)) +- update pre-commit hook asottile/pyupgrade to v3.10.1 ([#261](https://github.com/hetznercloud/hcloud-python/issues/261)) ([efa5780](https://github.com/hetznercloud/hcloud-python/commit/efa5780d0de3080bffe43994c064a0f1bcf6da43)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.1 ([#269](https://github.com/hetznercloud/hcloud-python/issues/269)) ([2239b0b](https://github.com/hetznercloud/hcloud-python/commit/2239b0bc9beae457215c6514b0b823cc84a4a463)) +- update pre-commit hook pycqa/flake8 to v6.1.0 ([#260](https://github.com/hetznercloud/hcloud-python/issues/260)) ([fd01384](https://github.com/hetznercloud/hcloud-python/commit/fd013842f7f94e98520ed403a8cd91b68a4c4e5c)) -## [1.22.0](https://github.com/hetznercloud/hcloud-python/compare/v1.21.0...v1.22.0) (2023-06-22) +### Documentation +- update documentation ([#247](https://github.com/hetznercloud/hcloud-python/issues/247)) ([e63741f](https://github.com/hetznercloud/hcloud-python/commit/e63741fab50524f4e4098af5c77f806915ae93c8)) +- update hetzner logo ([#264](https://github.com/hetznercloud/hcloud-python/issues/264)) ([ee79851](https://github.com/hetznercloud/hcloud-python/commit/ee79851dbf00e50d7f6b398fd4323f3e14831831)) + +## [1.26.0](https://github.com/hetznercloud/hcloud-python/compare/v1.25.0...v1.26.0) (2023-07-19) ### Features -* adhere to PEP 517 ([#213](https://github.com/hetznercloud/hcloud-python/issues/213)) ([7a19add](https://github.com/hetznercloud/hcloud-python/commit/7a19addd8b5200f8e61360657964233e7bfae13d)) -* bump required python version to >=3.7 ([#198](https://github.com/hetznercloud/hcloud-python/issues/198)) ([62d89f9](https://github.com/hetznercloud/hcloud-python/commit/62d89f94a8a86babd8ab238443054ca4cd9411ef)) -* **network:** add field expose_routes_to_vswitch ([#208](https://github.com/hetznercloud/hcloud-python/issues/208)) ([5321182](https://github.com/hetznercloud/hcloud-python/commit/5321182d084d03484431c8ad27da12875d255768)) -* setup exception hierarchy ([#199](https://github.com/hetznercloud/hcloud-python/issues/199)) ([8466645](https://github.com/hetznercloud/hcloud-python/commit/846664576a126472289464c0345eb9108c5f46d4)) +- add **repr** method to domains ([#246](https://github.com/hetznercloud/hcloud-python/issues/246)) ([4c22765](https://github.com/hetznercloud/hcloud-python/commit/4c227659bfb61551e44c41315b135039576960d3)) +- drop support for python 3.7 ([#242](https://github.com/hetznercloud/hcloud-python/issues/242)) ([2ce71e9](https://github.com/hetznercloud/hcloud-python/commit/2ce71e9ded5e9bb87ce96519ce59db942f4f9670)) +## [1.25.0](https://github.com/hetznercloud/hcloud-python/compare/v1.24.0...v1.25.0) (2023-07-14) -### Dependencies +### Features -* update actions/setup-python action to v4 ([#209](https://github.com/hetznercloud/hcloud-python/issues/209)) ([aeee575](https://github.com/hetznercloud/hcloud-python/commit/aeee575a8ea7c4a1afe312a2cc2624ee564a1408)) -* update actions/stale action to v8 ([#210](https://github.com/hetznercloud/hcloud-python/issues/210)) ([cb13230](https://github.com/hetznercloud/hcloud-python/commit/cb13230e570acdbb0287c678b4cee52a0a08a170)) -* update pre-commit hook asottile/pyupgrade to v3.7.0 ([#205](https://github.com/hetznercloud/hcloud-python/issues/205)) ([c46c5a4](https://github.com/hetznercloud/hcloud-python/commit/c46c5a49fcc127a21c73e958aa074ff37a2b9664)) +- add details to raise exceptions ([#240](https://github.com/hetznercloud/hcloud-python/issues/240)) ([cf64e54](https://github.com/hetznercloud/hcloud-python/commit/cf64e549a2b28aea91062dea67db8733b4ecdd6f)) +- move hcloud.hcloud module to hcloud.\_client ([#243](https://github.com/hetznercloud/hcloud-python/issues/243)) ([413472d](https://github.com/hetznercloud/hcloud-python/commit/413472d7af1602b872a9b56324b9bffd0067eee6)) -## [1.21.0](https://github.com/hetznercloud/hcloud-python/compare/v1.20.0...v1.21.0) (2023-06-19) +### Dependencies + +- update pre-commit hook asottile/pyupgrade to v3.9.0 ([#238](https://github.com/hetznercloud/hcloud-python/issues/238)) ([0053ded](https://github.com/hetznercloud/hcloud-python/commit/0053ded5a1d0c2407134706830dd8ff3d4d1e8ce)) +- update pre-commit hook pre-commit/mirrors-prettier to v3 ([#235](https://github.com/hetznercloud/hcloud-python/issues/235)) ([047d4e1](https://github.com/hetznercloud/hcloud-python/commit/047d4e173a53e91252d57d01b2e95def1c4949d9)) +- update pre-commit hook psf/black to v23.7.0 ([#239](https://github.com/hetznercloud/hcloud-python/issues/239)) ([443bf26](https://github.com/hetznercloud/hcloud-python/commit/443bf262cb524dd674d2007db8100fec94dab80d)) +## [1.24.0](https://github.com/hetznercloud/hcloud-python/compare/v1.23.1...v1.24.0) (2023-07-03) ### Features -* add deprecation field to ServerType ([#192](https://github.com/hetznercloud/hcloud-python/issues/192)) ([4a0fce7](https://github.com/hetznercloud/hcloud-python/commit/4a0fce7da6d47a7e9094c5efd1769d3d9395b540)) +- revert remove python-dateutil dependency ([#231](https://github.com/hetznercloud/hcloud-python/issues/231)) ([945bfde](https://github.com/hetznercloud/hcloud-python/commit/945bfde2ff0f64896e5c4d017e69236913e9d9dd)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) +### Dependencies + +- update pre-commit hook asottile/pyupgrade to v3.8.0 ([#232](https://github.com/hetznercloud/hcloud-python/issues/232)) ([27f21bc](https://github.com/hetznercloud/hcloud-python/commit/27f21bc41e17a800a8a3bed1df7935e7fb31de42)) + +## [1.23.1](https://github.com/hetznercloud/hcloud-python/compare/v1.23.0...v1.23.1) (2023-06-30) ### Bug Fixes -* adjust label validation for max length of 63 characters ([#194](https://github.com/hetznercloud/hcloud-python/issues/194)) ([3cba96d](https://github.com/hetznercloud/hcloud-python/commit/3cba96d261499e5f812aca7936ae9ed1e75ccd52)) +- handle Z timezone in ISO8601 datetime format ([#228](https://github.com/hetznercloud/hcloud-python/issues/228)) ([6a5c3f4](https://github.com/hetznercloud/hcloud-python/commit/6a5c3f42c092610c4a82cb79c0052499563549dc)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) +## [1.23.0](https://github.com/hetznercloud/hcloud-python/compare/v1.22.0...v1.23.0) (2023-06-26) -### Documentation +### Features -* improve branding, design & fix warnings ([#191](https://github.com/hetznercloud/hcloud-python/issues/191)) ([47eb9f1](https://github.com/hetznercloud/hcloud-python/commit/47eb9f1c79e05a61084f0a639f9497beb22d6910)) -* use venv for the dev setup ([#196](https://github.com/hetznercloud/hcloud-python/issues/196)) ([93f48ff](https://github.com/hetznercloud/hcloud-python/commit/93f48ff27c0561f66e5fe871e42fc2953bab0993)) +- remove python-dateutil dependency ([#221](https://github.com/hetznercloud/hcloud-python/issues/221)) ([8ea4aa0](https://github.com/hetznercloud/hcloud-python/commit/8ea4aa0ad12e85eeb14c81dfa2195e1a6ee79a76)) -## [1.20.0](https://github.com/hetznercloud/hcloud-python/compare/v1.19.0...v1.20.0) (2023-05-12) +### Bug Fixes +- **isos:** invalid name for include_wildcard_architecture argument ([#222](https://github.com/hetznercloud/hcloud-python/issues/222)) ([c3dfcab](https://github.com/hetznercloud/hcloud-python/commit/c3dfcaba44d88fcf6913a6e68caee2afde06e551)) + +### Dependencies + +- update dependency pytest to >=7.4,<7.5 ([#217](https://github.com/hetznercloud/hcloud-python/issues/217)) ([11e1f45](https://github.com/hetznercloud/hcloud-python/commit/11e1f455611b17a22328b3422d0b800552ea91e3)) + +## [1.22.0](https://github.com/hetznercloud/hcloud-python/compare/v1.21.0...v1.22.0) (2023-06-22) ### Features - * **server_type:** add field for included traffic ([#185](https://github.com/hetznercloud/hcloud-python/issues/185)) ([8ae0bc6](https://github.com/hetznercloud/hcloud-python/commit/8ae0bc6e032440538f3aeb2222a9bee34adab04b)) +- adhere to PEP 517 ([#213](https://github.com/hetznercloud/hcloud-python/issues/213)) ([7a19add](https://github.com/hetznercloud/hcloud-python/commit/7a19addd8b5200f8e61360657964233e7bfae13d)) +- bump required python version to >=3.7 ([#198](https://github.com/hetznercloud/hcloud-python/issues/198)) ([62d89f9](https://github.com/hetznercloud/hcloud-python/commit/62d89f94a8a86babd8ab238443054ca4cd9411ef)) +- **network:** add field expose_routes_to_vswitch ([#208](https://github.com/hetznercloud/hcloud-python/issues/208)) ([5321182](https://github.com/hetznercloud/hcloud-python/commit/5321182d084d03484431c8ad27da12875d255768)) +- setup exception hierarchy ([#199](https://github.com/hetznercloud/hcloud-python/issues/199)) ([8466645](https://github.com/hetznercloud/hcloud-python/commit/846664576a126472289464c0345eb9108c5f46d4)) + +### Dependencies + +- update actions/setup-python action to v4 ([#209](https://github.com/hetznercloud/hcloud-python/issues/209)) ([aeee575](https://github.com/hetznercloud/hcloud-python/commit/aeee575a8ea7c4a1afe312a2cc2624ee564a1408)) +- update actions/stale action to v8 ([#210](https://github.com/hetznercloud/hcloud-python/issues/210)) ([cb13230](https://github.com/hetznercloud/hcloud-python/commit/cb13230e570acdbb0287c678b4cee52a0a08a170)) +- update pre-commit hook asottile/pyupgrade to v3.7.0 ([#205](https://github.com/hetznercloud/hcloud-python/issues/205)) ([c46c5a4](https://github.com/hetznercloud/hcloud-python/commit/c46c5a49fcc127a21c73e958aa074ff37a2b9664)) + +## [1.21.0](https://github.com/hetznercloud/hcloud-python/compare/v1.20.0...v1.21.0) (2023-06-19) + +### Features + +- add deprecation field to ServerType ([#192](https://github.com/hetznercloud/hcloud-python/issues/192)) ([4a0fce7](https://github.com/hetznercloud/hcloud-python/commit/4a0fce7da6d47a7e9094c5efd1769d3d9395b540)) + +### Bug Fixes + +- adjust label validation for max length of 63 characters ([#194](https://github.com/hetznercloud/hcloud-python/issues/194)) ([3cba96d](https://github.com/hetznercloud/hcloud-python/commit/3cba96d261499e5f812aca7936ae9ed1e75ccd52)) + +### Documentation + +- improve branding, design & fix warnings ([#191](https://github.com/hetznercloud/hcloud-python/issues/191)) ([47eb9f1](https://github.com/hetznercloud/hcloud-python/commit/47eb9f1c79e05a61084f0a639f9497beb22d6910)) +- use venv for the dev setup ([#196](https://github.com/hetznercloud/hcloud-python/issues/196)) ([93f48ff](https://github.com/hetznercloud/hcloud-python/commit/93f48ff27c0561f66e5fe871e42fc2953bab0993)) + +## [1.20.0](https://github.com/hetznercloud/hcloud-python/compare/v1.19.0...v1.20.0) (2023-05-12) + +### Features +- **server_type:** add field for included traffic ([#185](https://github.com/hetznercloud/hcloud-python/issues/185)) ([8ae0bc6](https://github.com/hetznercloud/hcloud-python/commit/8ae0bc6e032440538f3aeb2222a9bee34adab04b)) ## v1.19.0 (2023-04-12) @@ -347,7 +767,7 @@ - chore: update tests to use released python-3.11 by @apricote in [#175](https://github.com/hetznercloud/hcloud-python/issues/175) - chore: prepare release 1.18.2 by @apricote in [#174](https://github.com/hetznercloud/hcloud-python/issues/174) -## v1.18.1 (2022-10-25) +## v1.18.1 (2022-10-25) - Update Github Actions by @LKaemmerling in [#165](https://github.com/hetznercloud/hcloud-python/issues/165) - Add tests for Python 3.11 by @LKaemmerling in [#167](https://github.com/hetznercloud/hcloud-python/issues/167) @@ -502,7 +922,7 @@ Fix: Ignore unknown fields in API response instead of raising an error - First stable release. - You can find the documentation under https://hcloud-python.readthedocs.io/en/latest/ + You can find the documentation under https://hcloud-python.readthedocs.io/en/stable/ ## 0.1.0 (2018-12-20) diff --git a/Makefile b/Makefile index b63a6ff2..cd3ea5e9 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ venv: lint: venv venv/bin/pylint hcloud + venv/bin/pylint tests --disable=missing-function-docstring,use-dict-literal,protected-access,redefined-outer-name,unnecessary-dunder-call venv/bin/mypy hcloud test: venv @@ -15,8 +16,7 @@ test: venv coverage: venv venv/bin/coverage run -m pytest -v venv/bin/coverage report --show-missing - venv/bin/coverage html - xdg-open htmlcov/index.html + venv/bin/coverage xml export SPHINXBUILD=../venv/bin/sphinx-build docs: venv diff --git a/README.md b/README.md index 780be7c2..50da524b 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,19 @@ [![](https://github.com/hetznercloud/hcloud-python/actions/workflows/test.yml/badge.svg)](https://github.com/hetznercloud/hcloud-python/actions/workflows/test.yml) [![](https://github.com/hetznercloud/hcloud-python/actions/workflows/lint.yml/badge.svg)](https://github.com/hetznercloud/hcloud-python/actions/workflows/lint.yml) [![](https://codecov.io/github/hetznercloud/hcloud-python/graph/badge.svg?token=3YGRqB5t1L)](https://codecov.io/github/hetznercloud/hcloud-python/tree/main) -[![](https://readthedocs.org/projects/hcloud-python/badge/?version=latest)](https://hcloud-python.readthedocs.io) +[![](https://app.readthedocs.org/projects/hcloud-python/badge/?version=latest)](https://hcloud-python.readthedocs.io/en/stable/) [![](https://img.shields.io/pypi/pyversions/hcloud.svg)](https://pypi.org/project/hcloud/) Official Hetzner Cloud python library. -The library's documentation is available at [hcloud-python.readthedocs.io](https://hcloud-python.readthedocs.io), the public API documentation is available at [docs.hetzner.cloud](https://docs.hetzner.cloud). +The library's documentation is available at [hcloud-python.readthedocs.io](https://hcloud-python.readthedocs.io/en/stable/), the public API documentation is available at [docs.hetzner.cloud](https://docs.hetzner.cloud). + +> [!IMPORTANT] +> Make sure to follow our API changelog available at +> [docs.hetzner.cloud/changelog](https://docs.hetzner.cloud/changelog) (or the RSS feed +> available at +> [docs.hetzner.cloud/changelog/feed.rss](https://docs.hetzner.cloud/changelog/feed.rss)) +> to be notified about additions, deprecations and removals. ## Usage @@ -27,12 +34,16 @@ from hcloud import Client from hcloud.images import Image from hcloud.server_types import ServerType -client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here +client = Client( + token="{YOUR_API_TOKEN}", # Please paste your API token here + application_name="my-app", + application_version="v1.0.0", +) # Create a server named my-server response = client.servers.create( name="my-server", - server_type=ServerType(name="cx11"), + server_type=ServerType(name="cx23"), image=Image(name="ubuntu-22.04"), ) server = response.server @@ -45,14 +56,34 @@ for server in servers: print(f"{server.id=} {server.name=} {server.status=}") ``` -For more details, please see the [API reference](https://hcloud-python.readthedocs.io/en/stable/api.html). - -You can find some more examples under the [`examples/`](https://github.com/hetznercloud/hcloud-python/tree/main/examples) directory. +- To upgrade the package, please read the [instructions available in the documentation](https://hcloud-python.readthedocs.io/en/stable/upgrading.html). +- For more details on the API, please see the [API reference](https://hcloud-python.readthedocs.io/en/stable/api.html). +- You can find some more examples under the [`examples/`](https://github.com/hetznercloud/hcloud-python/tree/main/examples) directory. ## Supported Python versions We support python versions until [`end-of-life`](https://devguide.python.org/versions/#status-of-python-versions). +## Experimental features + +Experimental features are published as part of our regular releases (e.g. a product +public beta). During an experimental phase, breaking changes on those features may occur +within minor releases. + +The stability of experimental features is not related to the stability of its upstream API. + +Experimental features have different levels of maturity (e.g. experimental, alpha, beta) +based on the maturity of the upstream API. + +While experimental features will be announced in the release notes, you can also find +whether a python class or function is experimental in its docstring: + +``` +Experimental: + $PRODUCT is $MATURITY, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#$SLUG for more details. +``` + ## Development First, create a virtual environment and activate it: @@ -119,6 +150,21 @@ warnings.warn( ) ``` +### Releasing experimental features + +To publish experimental features as part of regular releases: + +- an announcement, including a link to a changelog entry, must be added to the release notes. +- an `Experimental` notice, including a link to a changelog entry, must be added to the python classes and functions that are experimental: + + ```py + """ + Experimental: + $PRODUCT is $MATURITY, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#$SLUG for more details. + """ + ``` + ## License The MIT License (MIT). Please see [`License File`](https://github.com/hetznercloud/hcloud-python/blob/main/LICENSE) for more information. diff --git a/docs/api.clients.load_balancers.rst b/docs/api.clients.load_balancers.rst index 771eadfc..dfb2780a 100644 --- a/docs/api.clients.load_balancers.rst +++ b/docs/api.clients.load_balancers.rst @@ -20,7 +20,7 @@ LoadBalancerClient .. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealthCheck :members: -.. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealtCheckHttp +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp :members: .. autoclass:: hcloud.load_balancers.domain.LoadBalancerTarget diff --git a/docs/api.clients.storage_box_types.rst b/docs/api.clients.storage_box_types.rst new file mode 100644 index 00000000..9d58506e --- /dev/null +++ b/docs/api.clients.storage_box_types.rst @@ -0,0 +1,14 @@ +StorageBoxTypesClient +===================== + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesClient + :members: + +.. autoclass:: hcloud.storage_box_types.client.StorageBoxTypesPageResult + :members: + +.. autoclass:: hcloud.storage_box_types.client.BoundStorageBoxType + :members: + +.. autoclass:: hcloud.storage_box_types.domain.StorageBoxType + :members: diff --git a/docs/api.clients.storage_boxes.rst b/docs/api.clients.storage_boxes.rst new file mode 100644 index 00000000..604d9167 --- /dev/null +++ b/docs/api.clients.storage_boxes.rst @@ -0,0 +1,75 @@ +StorageBoxesClient +===================== + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesClient + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxesPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSnapshotsPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.StorageBoxSubaccountsPageResult + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSnapshot + :members: + +.. autoclass:: hcloud.storage_boxes.client.BoundStorageBoxSubaccount + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBox + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxAccessSettings + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotPlan + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStats + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxStatus + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshot + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSnapshotStats + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccount + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxSubaccountAccessSettings + :members: + + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.CreateStorageBoxSubaccountResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.StorageBoxFoldersResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSnapshotResponse + :members: + +.. autoclass:: hcloud.storage_boxes.domain.DeleteStorageBoxSubaccountResponse + :members: diff --git a/docs/api.clients.zones.rst b/docs/api.clients.zones.rst new file mode 100644 index 00000000..b8809675 --- /dev/null +++ b/docs/api.clients.zones.rst @@ -0,0 +1,30 @@ +ZonesClient +================== + + +.. autoclass:: hcloud.zones.client.ZonesClient + :members: + +.. autoclass:: hcloud.zones.client.BoundZone + :members: + +.. autoclass:: hcloud.zones.client.BoundZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.Zone + :members: + +.. autoclass:: hcloud.zones.domain.ZoneAuthoritativeNameservers + :members: + +.. autoclass:: hcloud.zones.domain.ZonePrimaryNameserver + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRecord + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.CreateZoneResponse + :members: diff --git a/docs/conf.py b/docs/conf.py index b3be9a7f..7992a6ef 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,7 +5,7 @@ from datetime import datetime sys.path.insert(0, os.path.abspath("..")) -from hcloud.__version__ import VERSION # noqa +import hcloud # noqa # Configuration file for the Sphinx documentation builder. # @@ -19,8 +19,8 @@ author = "Hetzner Cloud GmbH" copyright = f"{datetime.now().year}, {author}" -version = VERSION -release = VERSION +version = hcloud.__version__ +release = hcloud.__version__ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -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/docs/index.md b/docs/index.md index 915e125c..32c1f094 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,6 +7,7 @@ installation.rst api.rst Hetzner Cloud API Documentation contributing.rst +upgrading.md changelog.md ::: diff --git a/docs/upgrading.md b/docs/upgrading.md new file mode 100644 index 00000000..fbf79db8 --- /dev/null +++ b/docs/upgrading.md @@ -0,0 +1,78 @@ +# Upgrading + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Before upgrading, make sure to resolve any deprecation warnings. + +## Upgrading to v2 + +- [#397](https://github.com/hetznercloud/hcloud-python/pull/397): The package version was moved from `hcloud.__version__.VERSION` to `hcloud.__version__`, make sure to update your import paths: + +```diff +-from hcloud.__version__ import VERSION ++from hcloud import __version__ as VERSION +``` + +- [#401](https://github.com/hetznercloud/hcloud-python/pull/401): The deprecated `hcloud.hcloud` module was removed, make sure to update your import paths: + +```diff +-from hcloud.hcloud import Client ++from hcloud import Client +``` + +- [#398](https://github.com/hetznercloud/hcloud-python/pull/398): The [`Client.poll_interval`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client) property is now private, make sure to configure it while creating the [`Client`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client): + +```diff +-client = Client(token=token) +-client.poll_interval = 2 ++client = Client( ++ token=token, ++ poll_interval=2, ++) +``` + +- [#400](https://github.com/hetznercloud/hcloud-python/pull/400): The [`Client.request`](https://hcloud-python.readthedocs.io/en/stable/api.html#hcloud.Client.request) method now returns an empty dict instead of an empty string when the API response is empty: + +```diff + response = client.request(method="DELETE", url="/primary_ips/123456") +-assert response == "" ++assert response == {} +``` + +- [#402](https://github.com/hetznercloud/hcloud-python/pull/402): In the [`Client.isos.get_list`](https://hcloud-python.readthedocs.io/en/stable/api.clients.isos.html#hcloud.isos.client.IsosClient.get_list) and [`Client.isos.get_all`](https://hcloud-python.readthedocs.io/en/stable/api.clients.isos.html#hcloud.isos.client.IsosClient.get_all) methods, the deprecated `include_wildcard_architecture` argument was removed, make sure to use the `include_architecture_wildcard` argument instead: + +```diff + client.isos.get_all( +- include_wildcard_architecture=True, ++ include_architecture_wildcard=True, + ) +``` + +- [#363](https://github.com/hetznercloud/hcloud-python/pull/363): In the [`Client.primary_ips.create`](https://hcloud-python.readthedocs.io/en/stable/api.clients.primary_ips.html#hcloud.primary_ips.client.PrimaryIPsClient.create) method, the `datacenter` argument was moved after `name` argument and is now optional: + +```diff + client.primary_ips.create( + "ipv4", +- None, + "my-ip", + assignee_id=12345, + ) +``` + +```diff + client.primary_ips.create( + "ipv4", +- Datacenter(name="fsn1-dc14"), + "my-ip", ++ datacenter=Datacenter(name="fsn1-dc14"), + ) +``` + +- [#406](https://github.com/hetznercloud/hcloud-python/pull/406): In the [`Client.servers.rebuild`](https://hcloud-python.readthedocs.io/en/stable/api.clients.servers.html#hcloud.servers.client.ServersClient.rebuild) method, the single action return value was deprecated and is now removed. The method now returns a full response wrapping the action and an optional root password: + +```diff +-action = client.servers.rebuild(server, image) ++resp = client.servers.rebuild(server, image) ++action = resp.action ++root_password = resp.root_password +``` diff --git a/examples/create_server.py b/examples/create_server.py index d85c1d9d..7aea8fc7 100644 --- a/examples/create_server.py +++ b/examples/create_server.py @@ -11,12 +11,16 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) response = client.servers.create( name="my-server", - server_type=ServerType("cx11"), - image=Image(name="ubuntu-20.04"), + server_type=ServerType(name="cx23"), + image=Image(name="ubuntu-24.04"), ) server = response.server print(server) diff --git a/examples/get_server_metrics.py b/examples/get_server_metrics.py index cf91eaec..814a815c 100644 --- a/examples/get_server_metrics.py +++ b/examples/get_server_metrics.py @@ -1,7 +1,6 @@ from __future__ import annotations -import json -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from os import environ from hcloud import Client @@ -13,18 +12,22 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) server = client.servers.get_by_name("my-server") if server is None: response = client.servers.create( name="my-server", - server_type=ServerType("cx11"), - image=Image(name="ubuntu-22.04"), + server_type=ServerType(name="cx23"), + image=Image(name="ubuntu-24.04"), ) server = response.server -end = datetime.now() +end = datetime.now(timezone.utc) start = end - timedelta(hours=1) response = server.get_metrics( @@ -33,4 +36,4 @@ end=end, ) -print(json.dumps(response.metrics)) +print(response.metrics) diff --git a/examples/list_servers.py b/examples/list_servers.py index 82524301..64cc10e7 100644 --- a/examples/list_servers.py +++ b/examples/list_servers.py @@ -9,6 +9,10 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) servers = client.servers.get_all() print(servers) diff --git a/examples/usage_oop.py b/examples/usage_oop.py index 0a867cf6..f788306d 100644 --- a/examples/usage_oop.py +++ b/examples/usage_oop.py @@ -12,16 +12,20 @@ token = environ["HCLOUD_TOKEN"] # Create a client -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) # Create 2 servers # Create 2 servers response1 = client.servers.create( - "Server1", server_type=ServerType(name="cx11"), image=Image(id=4711) + "Server1", server_type=ServerType(name="cx23"), image=Image(id=4711) ) response2 = client.servers.create( - "Server2", server_type=ServerType(name="cx11"), image=Image(id=4711) + "Server2", server_type=ServerType(name="cx23"), image=Image(id=4711) ) # Get all servers server1 = response1.server diff --git a/examples/usage_procedurale.py b/examples/usage_procedurale.py index 8e901e60..6c88cbe7 100644 --- a/examples/usage_procedurale.py +++ b/examples/usage_procedurale.py @@ -13,15 +13,19 @@ ), "Please export your API token in the HCLOUD_TOKEN environment variable" token = environ["HCLOUD_TOKEN"] -client = Client(token=token) +client = Client( + token=token, + application_name="examples", + application_version="unknown", +) # Create 2 servers response1 = client.servers.create( - name="Server1", server_type=ServerType(name="cx11"), image=Image(id=4711) + name="Server1", server_type=ServerType(name="cx23"), image=Image(id=4711) ) response2 = client.servers.create( - "Server2", server_type=ServerType(name="cx11"), image=Image(id=4711) + "Server2", server_type=ServerType(name="cx23"), image=Image(id=4711) ) server1 = response1.server @@ -64,7 +68,7 @@ # Create one more server and attach 2 volumes to it client.servers.create( "Server3", - server_type=ServerType(name="cx11"), + server_type=ServerType(name="cx23"), image=Image(id=4711), volumes=[Volume(id=221), Volume(id=222)], ) diff --git a/hcloud/__init__.py b/hcloud/__init__.py index 6b69b169..8a9fa148 100644 --- a/hcloud/__init__.py +++ b/hcloud/__init__.py @@ -1,7 +1,18 @@ from __future__ import annotations -from ._client import Client as Client # noqa pylint: disable=C0414 -from ._exceptions import ( # noqa pylint: disable=C0414 - APIException as APIException, - HCloudException as HCloudException, +from ._client import ( + Client, + constant_backoff_function, + exponential_backoff_function, ) +from ._exceptions import APIException, HCloudException +from ._version import __version__ + +__all__ = [ + "__version__", + "Client", + "constant_backoff_function", + "exponential_backoff_function", + "APIException", + "HCloudException", +] diff --git a/hcloud/__version__.py b/hcloud/__version__.py deleted file mode 100644 index d350d713..00000000 --- a/hcloud/__version__.py +++ /dev/null @@ -1,3 +0,0 @@ -from __future__ import annotations - -VERSION = "1.35.0" # x-release-please-version diff --git a/hcloud/_client.py b/hcloud/_client.py index a12f204c..4a71bb0b 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -1,12 +1,15 @@ from __future__ import annotations import time -from typing import NoReturn +import warnings +from http import HTTPStatus +from random import uniform +from typing import Any, Protocol import requests -from .__version__ import VERSION from ._exceptions import APIException +from ._version import __version__ from .actions import ActionsClient from .certificates import CertificatesClient from .datacenters import DatacentersClient @@ -23,15 +26,116 @@ from .server_types import ServerTypesClient from .servers import ServersClient from .ssh_keys import SSHKeysClient +from .storage_box_types import StorageBoxTypesClient +from .storage_boxes import StorageBoxesClient from .volumes import VolumesClient +from .zones import ZonesClient + + +class BackoffFunction(Protocol): + def __call__(self, retries: int) -> float: + """ + Return a interval in seconds to wait between each API call. + + :param retries: Number of calls already made. + """ + + +def constant_backoff_function(interval: float) -> BackoffFunction: + """ + Return a backoff function, implementing a constant backoff. + + :param interval: Constant interval to return. + """ + + # pylint: disable=unused-argument + def func(retries: int) -> float: + return interval + + return func + + +def exponential_backoff_function( + *, + base: float, + multiplier: int, + cap: float, + jitter: bool = False, +) -> BackoffFunction: + """ + Return a backoff function, implementing a truncated exponential backoff with + optional full jitter. + + :param base: Base for the exponential backoff algorithm. + :param multiplier: Multiplier for the exponential backoff algorithm. + :param cap: Value at which the interval is truncated. + :param jitter: Whether to add jitter. + """ + + def func(retries: int) -> float: + interval: float = base * multiplier**retries # Exponential backoff + interval = min(cap, interval) # Cap backoff + if jitter: + interval = uniform(base, interval) # Add jitter + return interval + + return func + + +def _build_user_agent( + application_name: str | None, + application_version: str | None, +) -> str: + """Build the user agent of the hcloud-python instance with the user application name (if specified) + + :return: The user agent of this hcloud-python instance + """ + parts = [] + for name, version in [ + (application_name, application_version), + ("hcloud-python", __version__), + ]: + if name is not None: + parts.append(name if version is None else f"{name}/{version}") + + return " ".join(parts) class Client: - """Base Client for accessing the Hetzner Cloud API""" + """ + Client for the Hetzner Cloud API. + + The Hetzner Cloud API reference is available at https://docs.hetzner.cloud. + + Make sure to follow our API changelog available at + https://docs.hetzner.cloud/changelog (or the RRS feed available at + https://docs.hetzner.cloud/changelog/feed.rss) to be notified about additions, + deprecations and removals. + + **Retry mechanism** + + The :attr:`Client.request` method will retry failed requests that match certain criteria. The + default retry interval is defined by an exponential backoff algorithm truncated to 60s + with jitter. The default maximal number of retries is 5. + + The following rules define when a request can be retried: + + - When the client returned a network timeout error. + - When the API returned an HTTP error, with the status code: + + - ``502`` Bad Gateway + - ``504`` Gateway Timeout + + - When the API returned an application error, with the code: - _version = VERSION - _retry_wait_time = 0.5 - __user_agent_prefix = "hcloud-python" + - ``conflict`` + - ``rate_limit_exceeded`` + - ``bad_gateway`` + - ``timeout`` + + Changes to the retry policy might occur between releases, and will not be considered + breaking changes. + """ def __init__( self, @@ -39,31 +143,49 @@ def __init__( api_endpoint: str = "https://api.hetzner.cloud/v1", application_name: str | None = None, application_version: str | None = None, - poll_interval: int = 1, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, timeout: float | tuple[float, float] | None = None, + *, + api_endpoint_hetzner: str = "https://api.hetzner.com/v1", ): """Create a new Client instance :param token: Hetzner Cloud API token :param api_endpoint: Hetzner Cloud API endpoint + :param api_endpoint_hetzner: Hetzner API endpoint. :param application_name: Your application name :param application_version: Your application _version - :param poll_interval: Interval for polling information from Hetzner Cloud API in seconds + :param poll_interval: + Interval in seconds to use when polling actions from the API. + You may pass a function to compute a custom poll interval. + :param poll_max_retries: + Max retries before timeout when polling actions from the API. :param timeout: Requests timeout in seconds """ - self.token = token - self._api_endpoint = api_endpoint - self._application_name = application_name - self._application_version = application_version - self._requests_session = requests.Session() - self._requests_timeout = timeout - self.poll_interval = poll_interval + self._client = ClientBase( + token=token, + endpoint=api_endpoint, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) + self._client_hetzner = ClientBase( + token=token, + endpoint=api_endpoint_hetzner, + application_name=application_name, + application_version=application_version, + poll_interval=poll_interval, + poll_max_retries=poll_max_retries, + timeout=timeout, + ) - self.datacenters = DatacentersClient(self) - """DatacentersClient Instance + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + self.datacenters = DatacentersClient(self) - :type: :class:`DatacentersClient ` - """ self.locations = LocationsClient(self) """LocationsClient Instance @@ -149,85 +271,186 @@ def __init__( :type: :class:`PlacementGroupsClient ` """ - def _get_user_agent(self) -> str: - """Get the user agent of the hcloud-python instance with the user application name (if specified) + self.zones = ZonesClient(self) + """ZonesClient Instance - :return: The user agent of this hcloud-python instance + :type: :class:`ZonesClient ` """ - user_agents = [] - for name, version in [ - (self._application_name, self._application_version), - (self.__user_agent_prefix, self._version), - ]: - if name is not None: - user_agents.append(name if version is None else f"{name}/{version}") - return " ".join(user_agents) + self.storage_box_types = StorageBoxTypesClient(self) + """StorageBoxTypesClient Instance - def _get_headers(self) -> dict: - headers = { - "User-Agent": self._get_user_agent(), - "Authorization": f"Bearer {self.token}", - } - return headers + :type: :class:`StorageBoxTypesClient ` + """ - def _raise_exception_from_response(self, response: requests.Response) -> NoReturn: - raise APIException( - code=response.status_code, - message=response.reason, - details={"content": response.content}, - ) + self.storage_boxes = StorageBoxesClient(self) + """StorageBoxesClient Instance - def _raise_exception_from_content(self, content: dict) -> NoReturn: - raise APIException( - code=content["error"]["code"], - message=content["error"]["message"], - details=content["error"]["details"], - ) + :type: :class:`StorageBoxesClient ` + """ def request( # type: ignore[no-untyped-def] self, method: str, url: str, - tries: int = 1, **kwargs, - ) -> dict: - """Perform a request to the Hetzner Cloud API, wrapper around requests.request + ) -> dict[str, Any]: + """Perform a request to the Hetzner Cloud API. - :param method: HTTP Method to perform the Request - :param url: URL of the Endpoint - :param tries: Tries of the request (used internally, should not be set by the user) - :param timeout: Requests timeout in seconds - :return: Response + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. """ - timeout = kwargs.pop("timeout", self._requests_timeout) + return self._client.request(method, url, **kwargs) - response = self._requests_session.request( - method=method, - url=self._api_endpoint + url, - headers=self._get_headers(), - timeout=timeout, - **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__( + self, + token: str, + *, + endpoint: str, + application_name: str | None = None, + application_version: str | None = None, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, + timeout: float | tuple[float, float] | None = None, + ): + self._token = token + self._endpoint = endpoint + + self._user_agent = _build_user_agent(application_name, application_version) + self._headers = { + "User-Agent": self._user_agent, + "Authorization": f"Bearer {self._token}", + "Accept": "application/json", + } + + if isinstance(poll_interval, (int, float)): + poll_interval_func = constant_backoff_function(poll_interval) + else: + poll_interval_func = poll_interval + + self._poll_interval_func = poll_interval_func + self._poll_max_retries = poll_max_retries + + self._retry_interval_func = exponential_backoff_function( + base=1.0, multiplier=2, cap=60.0, jitter=True ) + self._retry_max_retries = 5 - content = response.content + self._timeout = timeout + self._session = requests.Session() + + def request( # type: ignore[no-untyped-def] + self, + method: str, + url: str, + **kwargs, + ) -> dict[str, Any]: + """Perform a request to the provided URL. + + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. + :return: Response + """ + kwargs.setdefault("timeout", self._timeout) + + url = self._endpoint + url + headers = self._headers + + retries = 0 + while True: + try: + response = self._session.request( + method=method, + url=url, + headers=headers, + **kwargs, + ) + return self._read_response(response) + except APIException as exception: + if retries < self._retry_max_retries and self._retry_policy(exception): + time.sleep(self._retry_interval_func(retries)) + retries += 1 + continue + raise + except requests.exceptions.Timeout: + if retries < self._retry_max_retries: + time.sleep(self._retry_interval_func(retries)) + retries += 1 + continue + raise + + def _read_response(self, response: requests.Response) -> dict[str, Any]: + correlation_id = response.headers.get("X-Correlation-Id") + payload = {} try: - if len(content) > 0: - content = response.json() - except (TypeError, ValueError): - self._raise_exception_from_response(response) + if len(response.content) > 0: + payload = response.json() + except (TypeError, ValueError) as exc: + raise APIException( + code=response.status_code, + message=response.reason, + details={"content": response.content}, + correlation_id=correlation_id, + ) from exc if not response.ok: - if content: - assert isinstance(content, dict) - if content["error"]["code"] == "rate_limit_exceeded" and tries < 5: - time.sleep(tries * self._retry_wait_time) - tries = tries + 1 - return self.request(method, url, tries, **kwargs) - - self._raise_exception_from_content(content) - else: - self._raise_exception_from_response(response) - - # TODO: return an empty dict instead of an empty string when content == "". - return content # type: ignore[return-value] + if not payload or "error" not in payload: + raise APIException( + code=response.status_code, + message=response.reason, + details={"content": response.content}, + correlation_id=correlation_id, + ) + + error: dict[str, Any] = payload["error"] + raise APIException( + code=error["code"], + message=error["message"], + details=error.get("details"), + correlation_id=correlation_id, + ) + + return payload + + def _retry_policy(self, exception: APIException) -> bool: + if isinstance(exception.code, str): + return exception.code in ( + "rate_limit_exceeded", + "conflict", + "bad_gateway", + "timeout", + ) + + if isinstance(exception.code, int): + return exception.code in ( + HTTPStatus.BAD_GATEWAY, + HTTPStatus.GATEWAY_TIMEOUT, + ) + + return False diff --git a/hcloud/_exceptions.py b/hcloud/_exceptions.py index 877083f8..e33105a3 100644 --- a/hcloud/_exceptions.py +++ b/hcloud/_exceptions.py @@ -4,14 +4,32 @@ class HCloudException(Exception): - """There was an error while using the hcloud library""" + """There was an error while using the hcloud library. + + All exceptions in the hcloud library inherit from this exception. It may be used as + catch-all exception. + """ class APIException(HCloudException): - """There was an error while performing an API Request""" + """There was an error while performing an API Request.""" + + def __init__( + self, + code: int | str, + message: str, + details: Any, + *, + correlation_id: str | None = None, + ): + extras = [str(code)] + if correlation_id is not None: + extras.append(correlation_id) + + error = f"{message} ({', '.join(extras)})" - def __init__(self, code: int | str, message: str | None, details: Any): - super().__init__(code if message is None and isinstance(code, str) else message) + super().__init__(error) self.code = code self.message = message self.details = details + self.correlation_id = correlation_id diff --git a/hcloud/_version.py b/hcloud/_version.py new file mode 100644 index 00000000..8408a231 --- /dev/null +++ b/hcloud/_version.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__version__ = "2.23.0" # x-releaser-pleaser-version diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index ca93c89f..ac677936 100644 --- a/hcloud/actions/__init__.py +++ b/hcloud/actions/__init__.py @@ -1,14 +1,33 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( ActionsClient, + ActionSort, ActionsPageResult, BoundAction, ResourceActionsClient, ) -from .domain import ( # noqa: F401 +from .domain import ( Action, + ActionError, ActionException, ActionFailedException, + ActionResource, + ActionStatus, ActionTimeoutException, ) + +__all__ = [ + "ActionsClient", + "ActionsPageResult", + "BoundAction", + "ResourceActionsClient", + "ActionSort", + "ActionStatus", + "Action", + "ActionResource", + "ActionError", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", +] diff --git a/hcloud/actions/client.py b/hcloud/actions/client.py index a188f624..176a6b18 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -2,84 +2,105 @@ import time import warnings -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any, Literal, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta -from .domain import Action, ActionFailedException, ActionTimeoutException +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import Action, ActionFailedException, ActionStatus, ActionTimeoutException if TYPE_CHECKING: from .._client import Client -class BoundAction(BoundModelBase, Action): +__all__ = [ + "ActionsClient", + "ActionsPageResult", + "BoundAction", + "ResourceActionsClient", + "ActionSort", +] + + +class BoundAction(BoundModelBase[Action], Action): _client: ActionsClient model = Action - def wait_until_finished(self, max_retries: int = 100) -> None: - """Wait until the specific action has status="finished" (set Client.poll_interval to specify a delay between checks) + def wait_until_finished(self, max_retries: int | None = None) -> None: + """Wait until the specific action has status=finished. - :param max_retries: int - Specify how many retries will be performed before an ActionTimeoutException will be raised - :raises: ActionFailedException when action is finished with status=="error" - :raises: ActionTimeoutException when Action is still in "running" state after max_retries reloads. + :param max_retries: int Specify how many retries will be performed before an ActionTimeoutException will be raised. + :raises: ActionFailedException when action is finished with status==error + :raises: ActionTimeoutException when Action is still in status==running after max_retries is reached. """ - while self.status == Action.STATUS_RUNNING: - if max_retries > 0: - self.reload() + if max_retries is None: + # pylint: disable=protected-access + max_retries = self._client._client._poll_max_retries + + retries = 0 + while True: + self.reload() + if self.status != Action.STATUS_RUNNING: + break + + retries += 1 + if retries < max_retries: # pylint: disable=protected-access - time.sleep(self._client._client.poll_interval) - max_retries = max_retries - 1 - else: - raise ActionTimeoutException(action=self) + time.sleep(self._client._client._poll_interval_func(retries)) + continue + + raise ActionTimeoutException(action=self) if self.status == Action.STATUS_ERROR: raise ActionFailedException(action=self) -class ActionsPageResult(NamedTuple): - actions: list[BoundAction] - meta: Meta | None - +ActionSort = Literal[ + "id", + "id:asc", + "id:desc", + "command", + "command:asc", + "command:desc", + "status", + "status:asc", + "status:desc", + "started", + "started:asc", + "started:desc", + "finished", + "finished:asc", + "finished:desc", +] -class ResourceActionsClient(ClientEntityBase): - _resource: str - def __init__(self, client: Client, resource: str | None): - super().__init__(client) - self._resource = resource or "" +class ActionsPageResult(NamedTuple): + actions: list[BoundAction] + meta: Meta - def get_by_id(self, id: int) -> BoundAction: - """Get a specific action by its ID. - :param id: int - :return: :class:`BoundAction ` - """ +class ResourceClientBaseActionsMixin(ResourceClientBase): + def _get_action_by_id( + self, + base_url: str, + id: int, + ) -> BoundAction: response = self._client.request( - url=f"{self._resource}/actions/{id}", method="GET", + url=f"{base_url}/actions/{id}", + ) + return BoundAction( + client=self._parent.actions, + data=response["action"], ) - return BoundAction(self._client.actions, response["action"]) - def get_list( + def _get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + base_url: str, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Get a list of actions. - - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) - """ params: dict[str, Any] = {} if status is not None: params["status"] = status @@ -91,28 +112,75 @@ def get_list( params["per_page"] = per_page response = self._client.request( - url=f"{self._resource}/actions", method="GET", + url=f"{base_url}/actions", params=params, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + +class ResourceActionsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _resource: str + + def __init__(self, client: ResourceClientBase | Client, resource: str | None): + if isinstance(client, ResourceClientBase): + super().__init__(client._parent) + # Use the same base client as the the resource base client. Allows us to + # choose the base client outside of the ResourceActionsClient. + self._client = client._client + else: + # Backward compatibility, defaults to the parent ("top level") base client (`_client`). + super().__init__(client) + + self._resource = resource or "" + + def get_by_id(self, id: int) -> BoundAction: + """ + Returns a specific Action by its ID. + + :param id: ID of the Action. + """ + return self._get_action_by_id(self._resource, id) + + def get_list( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions. + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + self._resource, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_all( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Get all actions. + """ + Returns all Actions. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages(self.get_list, status=status, sort=sort) @@ -123,8 +191,8 @@ def __init__(self, client: Client): def get_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: @@ -146,8 +214,8 @@ def get_list( def get_all( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: """ .. deprecated:: 1.28 diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index 6950aea4..59b7f442 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +from typing import TYPE_CHECKING, Any, Literal, TypedDict from .._exceptions import HCloudException from ..core import BaseDomain @@ -10,6 +8,22 @@ if TYPE_CHECKING: from .client import BoundAction +__all__ = [ + "ActionStatus", + "Action", + "ActionResource", + "ActionError", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", +] + +ActionStatus = Literal[ + "running", + "success", + "error", +] + class Action(BaseDomain): """Action Domain @@ -31,7 +45,7 @@ class Action(BaseDomain): STATUS_ERROR = "error" """Action Status error""" - __slots__ = ( + __api_properties__ = ( "id", "command", "status", @@ -41,38 +55,64 @@ class Action(BaseDomain): "started", "finished", ) + __slots__ = __api_properties__ def __init__( self, id: int, command: str | None = None, - status: str | None = None, + status: ActionStatus | None = None, progress: int | None = None, started: str | None = None, finished: str | None = None, - resources: list[dict] | None = None, - error: dict | None = None, + resources: list[ActionResource] | None = None, + error: ActionError | None = None, ): self.id = id self.command = command self.status = status self.progress = progress - self.started = isoparse(started) if started else None - self.finished = isoparse(finished) if finished else None + self.started = self._parse_datetime(started) + self.finished = self._parse_datetime(finished) self.resources = resources self.error = error +class ActionResource(TypedDict): + id: int + type: str + + +class ActionError(TypedDict): + code: str + message: str + details: dict[str, Any] + + class ActionException(HCloudException): """A generic action exception""" def __init__(self, action: Action | BoundAction): assert self.__doc__ is not None message = self.__doc__ - if action.error is not None and "message" in action.error: + + extras = [] + if ( + action.error is not None + and "code" in action.error + and "message" in action.error + ): message += f": {action.error['message']}" + extras.append(action.error["code"]) + else: + if action.command is not None: + extras.append(action.command) + + extras.append(str(action.id)) + message += f" ({', '.join(extras)})" + super().__init__(message) self.message = message self.action = action diff --git a/hcloud/certificates/__init__.py b/hcloud/certificates/__init__.py index 4e63df57..5b85358e 100644 --- a/hcloud/certificates/__init__.py +++ b/hcloud/certificates/__init__.py @@ -1,13 +1,23 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundCertificate, CertificatesClient, CertificatesPageResult, ) -from .domain import ( # noqa: F401 +from .domain import ( Certificate, CreateManagedCertificateResponse, ManagedCertificateError, ManagedCertificateStatus, ) + +__all__ = [ + "BoundCertificate", + "Certificate", + "CertificatesClient", + "CertificatesPageResult", + "CreateManagedCertificateResponse", + "ManagedCertificateError", + "ManagedCertificateStatus", +] diff --git a/hcloud/certificates/client.py b/hcloud/certificates/client.py index a5fe1d77..46a6a538 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -2,8 +2,15 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( Certificate, CreateManagedCertificateResponse, @@ -15,12 +22,24 @@ from .._client import Client -class BoundCertificate(BoundModelBase, Certificate): +__all__ = [ + "BoundCertificate", + "CertificatesPageResult", + "CertificatesClient", +] + + +class BoundCertificate(BoundModelBase[Certificate], Certificate): _client: CertificatesClient model = Certificate - def __init__(self, client: CertificatesClient, data: dict, complete: bool = True): + def __init__( + self, + client: CertificatesClient, + data: dict[str, Any], + complete: bool = True, + ): status = data.get("status") if status is not None: error_data = status.get("error") @@ -36,39 +55,43 @@ def __init__(self, client: CertificatesClient, data: dict, complete: bool = True def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Certificate. + """ + Returns a paginated list of Actions for a Certificate. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Certificate. + """ + Returns all Actions for a Certificate. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -83,7 +106,11 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundCertificate ` """ - return self._client.update(self, name, labels) + return self._client.update( + self, + name=name, + labels=labels, + ) def delete(self) -> bool: """Deletes a certificate. @@ -100,11 +127,14 @@ def retry_issuance(self) -> BoundAction: class CertificatesPageResult(NamedTuple): certificates: list[BoundCertificate] - meta: Meta | None + meta: Meta -class CertificatesClient(ClientEntityBase): - _client: Client +class CertificatesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/certificates" actions: ResourceActionsClient """Certificates scoped actions client @@ -114,7 +144,7 @@ class CertificatesClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/certificates") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundCertificate: """Get a specific certificate by its ID. @@ -122,7 +152,7 @@ def get_by_id(self, id: int) -> BoundCertificate: :param id: int :return: :class:`BoundCertificate ` """ - response = self._client.request(url=f"/certificates/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundCertificate(self, response["certificate"]) def get_list( @@ -157,9 +187,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/certificates", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) certificates = [ BoundCertificate(self, certificate_data) @@ -190,7 +218,7 @@ def get_by_name(self, name: str) -> BoundCertificate | None: Used to get certificate by name. :return: :class:`BoundCertificate ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -219,7 +247,7 @@ def create( } if labels is not None: data["labels"] = labels - response = self._client.request(url="/certificates", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundCertificate(self, response["certificate"]) def create_managed( @@ -245,10 +273,10 @@ def create_managed( } if labels is not None: data["labels"] = labels - response = self._client.request(url="/certificates", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return CreateManagedCertificateResponse( certificate=BoundCertificate(self, response["certificate"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -272,7 +300,7 @@ def update( if labels is not None: data["labels"] = labels response = self._client.request( - url=f"/certificates/{certificate.id}", + url=f"{self._base_url}/{certificate.id}", method="PUT", json=data, ) @@ -285,7 +313,7 @@ def delete(self, certificate: Certificate | BoundCertificate) -> bool: :return: True """ self._client.request( - url=f"/certificates/{certificate.id}", + url=f"{self._base_url}/{certificate.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -294,59 +322,40 @@ def delete(self, certificate: Certificate | BoundCertificate) -> bool: def get_actions_list( self, certificate: Certificate | BoundCertificate, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Certificate. - - :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Certificate. - response = self._client.request( - url=f"/certificates/{certificate.id}/actions", - method="GET", - params=params, + :param certificate: Certificate to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{certificate.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, certificate: Certificate | BoundCertificate, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Certificate. + """ + Returns all Actions for a Certificate. - :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param certificate: Certificate to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -365,7 +374,7 @@ def retry_issuance( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/certificates/{certificate.id}/actions/retry", + url=f"{self._base_url}/{certificate.id}/actions/retry", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py index 20be3e33..677ab8f2 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -11,6 +9,14 @@ from .client import BoundCertificate +__all__ = [ + "Certificate", + "ManagedCertificateStatus", + "ManagedCertificateError", + "CreateManagedCertificateResponse", +] + + class Certificate(BaseDomain, DomainIdentityMixin): """Certificate Domain @@ -31,7 +37,7 @@ class Certificate(BaseDomain, DomainIdentityMixin): :param status: ManagedCertificateStatus Current status of a type managed Certificate, always none for type uploaded Certificates """ - __slots__ = ( + __api_properties__ = ( "id", "name", "certificate", @@ -44,6 +50,8 @@ class Certificate(BaseDomain, DomainIdentityMixin): "type", "status", ) + __slots__ = __api_properties__ + TYPE_UPLOADED = "uploaded" TYPE_MANAGED = "managed" @@ -67,9 +75,9 @@ def __init__( self.certificate = certificate self.domain_names = domain_names self.fingerprint = fingerprint - self.not_valid_before = isoparse(not_valid_before) if not_valid_before else None - self.not_valid_after = isoparse(not_valid_after) if not_valid_after else None - self.created = isoparse(created) if created else None + self.not_valid_before = self._parse_datetime(not_valid_before) + self.not_valid_after = self._parse_datetime(not_valid_after) + self.created = self._parse_datetime(created) self.labels = labels self.status = status @@ -85,6 +93,13 @@ class ManagedCertificateStatus(BaseDomain): If issuance or renewal reports failure, this property contains information about what happened """ + __api_properties__ = ( + "issuance", + "renewal", + "error", + ) + __slots__ = __api_properties__ + def __init__( self, issuance: str | None = None, @@ -105,6 +120,12 @@ class ManagedCertificateError(BaseDomain): Message detailing the error """ + __api_properties__ = ( + "code", + "message", + ) + __slots__ = __api_properties__ + def __init__(self, code: str | None = None, message: str | None = None): self.code = code self.message = message @@ -119,7 +140,8 @@ class CreateManagedCertificateResponse(BaseDomain): Shows the progress of the certificate creation """ - __slots__ = ("certificate", "action") + __api_properties__ = ("certificate", "action") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/core/__init__.py b/hcloud/core/__init__.py index 4e17dac9..4fb66e86 100644 --- a/hcloud/core/__init__.py +++ b/hcloud/core/__init__.py @@ -1,4 +1,14 @@ from __future__ import annotations -from .client import BoundModelBase, ClientEntityBase # noqa: F401 -from .domain import BaseDomain, DomainIdentityMixin, Meta, Pagination # noqa: F401 +from .client import BoundModelBase, ClientEntityBase, ResourceClientBase +from .domain import BaseDomain, DomainIdentityMixin, Meta, Pagination + +__all__ = [ + "BaseDomain", + "BoundModelBase", + "ClientEntityBase", + "DomainIdentityMixin", + "Meta", + "Pagination", + "ResourceClientBase", +] diff --git a/hcloud/core/client.py b/hcloud/core/client.py index d213daf0..45f5039c 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,29 +1,43 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable +import warnings +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar + +from .domain import BaseDomain if TYPE_CHECKING: - from .._client import Client + from .._client import Client, ClientBase + from .domain import Meta + +__all__ = [ + "ResourceClientBase", + "ClientEntityBase", + "BoundModelBase", +] + +T = TypeVar("T") -class ClientEntityBase: - _client: Client + +class ResourceClientBase: + _base_url: ClassVar[str] + _parent: Client + _client: ClientBase max_per_page: int = 50 def __init__(self, client: Client): - """ - :param client: Client - :return self - """ - self._client = client + self._parent = client + # Use the parent "default" base client. + self._client = client._client def _iter_pages( # type: ignore[no-untyped-def] self, - list_function: Callable, + list_function: Callable[..., tuple[list[T], Meta]], *args, **kwargs, - ) -> list: + ) -> list[T]: results = [] page = 1 @@ -43,22 +57,46 @@ def _iter_pages( # type: ignore[no-untyped-def] return results - def _get_first_by(self, **kwargs): # type: ignore[no-untyped-def] - assert hasattr(self, "get_list") - # pylint: disable=no-member - entities, _ = self.get_list(**kwargs) + def _get_first_by( # type: ignore[no-untyped-def] + self, + list_function: Callable[..., tuple[list[T], Meta]], + *args, + **kwargs, + ) -> T | None: + entities, _ = list_function(*args, **kwargs) return entities[0] if entities else None -class BoundModelBase: +class ClientEntityBase(ResourceClientBase): + """ + Kept for backward compatibility. + + .. deprecated:: 2.6.0 + Use :class:``hcloud.core.client.ResourceClientBase`` instead. + """ + + def __init__(self, client: Client): + warnings.warn( + "The 'hcloud.core.client.ClientEntityBase' class is deprecated, please use the " + "'hcloud.core.client.ResourceClientBase' class instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(client) + + +Domain = TypeVar("Domain", bound=BaseDomain) + + +class BoundModelBase(Generic[Domain]): """Bound Model Base""" - model: Any + model: type[Domain] def __init__( self, - client: ClientEntityBase, - data: dict, + client: ResourceClientBase, + data: dict[str, Any], complete: bool = True, ): """ @@ -71,7 +109,10 @@ def __init__( """ self._client = client self.complete = complete - self.data_model = 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 @@ -84,10 +125,14 @@ def __getattr__(self, name: str): # type: ignore[no-untyped-def] value = getattr(self.data_model, name) return value - def reload(self) -> None: - """Reloads the model and tries to get all data from the APIx""" + def _get_self(self) -> BoundModelBase[Domain]: assert hasattr(self._client, "get_by_id") - bound_model = self._client.get_by_id(self.data_model.id) + assert hasattr(self.data_model, "id") + return self._client.get_by_id(self.data_model.id) # type: ignore + + def reload(self) -> None: + """Reloads the model and tries to get all data from the API""" + bound_model = self._get_self() self.data_model = bound_model.data_model self.complete = True @@ -96,3 +141,9 @@ def __repr__(self) -> str: # models, as they will generate a lot of API call trying to print all the fields # of the model. return object.__repr__(self) + + def __eq__(self, other: Any) -> bool: + """Compare a bound model object with another of the same type.""" + if not isinstance(other, self.__class__): + return NotImplemented + return self.data_model == other.data_model diff --git a/hcloud/core/domain.py b/hcloud/core/domain.py index bba954fe..6659df80 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,24 +1,54 @@ from __future__ import annotations +from datetime import datetime +from typing import Any, overload + +from dateutil.parser import isoparse + +__all__ = [ + "BaseDomain", + "DomainIdentityMixin", + "Pagination", + "Meta", +] + class BaseDomain: - __slots__ = () + __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data: dict): # type: ignore[no-untyped-def] + def from_dict(cls, data: dict[str, Any]): # type: ignore[no-untyped-def] """ Build the domain object from the data dict. """ - supported_data = {k: v for k, v in data.items() if k in cls.__slots__} + supported_data = {k: v for k, v in data.items() if k in cls.__api_properties__} return cls(**supported_data) def __repr__(self) -> str: - kwargs = [f"{key}={getattr(self, key)!r}" for key in self.__slots__] # type: ignore[var-annotated] + kwargs = [f"{key}={getattr(self, key)!r}" for key in self.__api_properties__] return f"{self.__class__.__qualname__}({', '.join(kwargs)})" + def __eq__(self, other: Any) -> bool: + """Compare a domain object with another of the same type.""" + if not isinstance(other, self.__class__): + return NotImplemented + for key in self.__api_properties__: + if getattr(self, key) != getattr(other, key): + return False + return True + + @overload + def _parse_datetime(self, value: str) -> datetime: ... + @overload + def _parse_datetime(self, value: None) -> None: ... + + def _parse_datetime(self, value: str | None) -> datetime | None: + if value is None: + return None + return isoparse(value) + class DomainIdentityMixin: - __slots__ = () id: int | None name: str | None @@ -42,19 +72,26 @@ def has_id_or_name(self, id_or_name: int | str) -> bool: the comparison will not work as expected (e.g. the domains are the same but cannot be equal, if one provides an id and the other the name). """ - values: list[int | str] = [] + result = None + if self.id is not None: - values.append(self.id) + value = id_or_name + if isinstance(id_or_name, str) and id_or_name.isnumeric(): + value = int(id_or_name) + + result = result or self.id == value + if self.name is not None: - values.append(self.name) - if not values: + result = result or self.name == str(id_or_name) + + if result is None: raise ValueError("id or name must be set") - return id_or_name in values + return result class Pagination(BaseDomain): - __slots__ = ( + __api_properties__ = ( "page", "per_page", "previous_page", @@ -62,6 +99,7 @@ class Pagination(BaseDomain): "last_page", "total_entries", ) + __slots__ = __api_properties__ def __init__( self, @@ -81,19 +119,19 @@ def __init__( class Meta(BaseDomain): - __slots__ = ("pagination",) + __api_properties__ = ("pagination",) + __slots__ = __api_properties__ def __init__(self, pagination: Pagination | None = None): self.pagination = pagination @classmethod - def parse_meta(cls, response: dict) -> Meta | None: + def parse_meta(cls, response: dict[str, Any]) -> Meta: """ If present, extract the meta details from the response and return a meta object. """ - meta = None + meta = cls() if response and "meta" in response: - meta = cls() try: meta.pagination = Pagination(**response["meta"]["pagination"]) except KeyError: diff --git a/hcloud/datacenters/__init__.py b/hcloud/datacenters/__init__.py index 559694c0..fdfbb189 100644 --- a/hcloud/datacenters/__init__.py +++ b/hcloud/datacenters/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundDatacenter, DatacentersClient, DatacentersPageResult, ) -from .domain import Datacenter, DatacenterServerTypes # noqa: F401 +from .domain import Datacenter, DatacenterServerTypes + +__all__ = [ + "BoundDatacenter", + "Datacenter", + "DatacenterServerTypes", + "DatacentersClient", + "DatacentersPageResult", +] diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 1be1e126..f8a7f3b0 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,8 +1,9 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from ..server_types import BoundServerType from .domain import Datacenter, DatacenterServerTypes @@ -10,34 +11,46 @@ if TYPE_CHECKING: from .._client import Client +__all__ = [ + "BoundDatacenter", + "DatacentersPageResult", + "DatacentersClient", +] + + +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. + """ -class BoundDatacenter(BoundModelBase, Datacenter): _client: DatacentersClient model = Datacenter - def __init__(self, client: DatacentersClient, data: dict): + def __init__(self, client: DatacentersClient, data: dict[str, Any]): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) server_types = data.get("server_types") if server_types is not None: available = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available"] ] supported = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["supported"] ] available_for_migration = [ BoundServerType( - client._client.server_types, {"id": server_type}, complete=False + client._parent.server_types, {"id": server_type}, complete=False ) for server_type in server_types["available_for_migration"] ] @@ -51,12 +64,33 @@ def __init__(self, client: DatacentersClient, data: dict): 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 | None + 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" -class DatacentersClient(ClientEntityBase): - _client: Client + 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. @@ -64,7 +98,7 @@ def get_by_id(self, id: int) -> BoundDatacenter: :param id: int :return: :class:`BoundDatacenter ` """ - response = self._client.request(url=f"/datacenters/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundDatacenter(self, response["datacenter"]) def get_list( @@ -93,7 +127,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/datacenters", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) datacenters = [ BoundDatacenter(self, datacenter_data) @@ -118,4 +152,4 @@ def get_by_name(self, name: str) -> BoundDatacenter | None: Used to get datacenter by name. :return: :class:`BoundDatacenter ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/datacenters/domain.py b/hcloud/datacenters/domain.py index 05d5f793..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 @@ -8,10 +9,19 @@ from ..locations import Location from ..server_types import BoundServerType +__all__ = [ + "Datacenter", + "DatacenterServerTypes", +] + 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 @@ -19,7 +29,9 @@ class Datacenter(BaseDomain, DomainIdentityMixin): :param server_types: :class:`DatacenterServerTypes ` """ - __slots__ = ("id", "name", "description", "location", "server_types") + __properties__ = ("id", "name", "description", "location") + __api_properties__ = (*__properties__, "server_types") + __slots__ = (*__properties__, "_server_types") def __init__( self, @@ -29,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 `] @@ -47,7 +91,8 @@ class DatacenterServerTypes(BaseDomain): All available for migration (change type) server types for this datacenter """ - __slots__ = ("available", "supported", "available_for_migration") + __api_properties__ = ("available", "supported", "available_for_migration") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/deprecation/__init__.py b/hcloud/deprecation/__init__.py index 315576b1..4dd0e91f 100644 --- a/hcloud/deprecation/__init__.py +++ b/hcloud/deprecation/__init__.py @@ -1,3 +1,7 @@ from __future__ import annotations -from .domain import DeprecationInfo # noqa: F401 +from .domain import DeprecationInfo + +__all__ = [ + "DeprecationInfo", +] diff --git a/hcloud/deprecation/domain.py b/hcloud/deprecation/domain.py index cd1b5365..c78c43e6 100644 --- a/hcloud/deprecation/domain.py +++ b/hcloud/deprecation/domain.py @@ -1,9 +1,11 @@ from __future__ import annotations -from dateutil.parser import isoparse - from ..core import BaseDomain +__all__ = [ + "DeprecationInfo", +] + class DeprecationInfo(BaseDomain): """Describes if, when & how the resources was deprecated. If this field is set to ``None`` the resource is not @@ -17,17 +19,16 @@ class DeprecationInfo(BaseDomain): new servers with this image after the mentioned date. """ - __slots__ = ( + __api_properties__ = ( "announced", "unavailable_after", ) + __slots__ = __api_properties__ def __init__( self, announced: str | None = None, unavailable_after: str | None = None, ): - self.announced = isoparse(announced) if announced else None - self.unavailable_after = ( - isoparse(unavailable_after) if unavailable_after else None - ) + self.announced = self._parse_datetime(announced) + self.unavailable_after = self._parse_datetime(unavailable_after) diff --git a/hcloud/exp/__init__.py b/hcloud/exp/__init__.py new file mode 100644 index 00000000..1f470551 --- /dev/null +++ b/hcloud/exp/__init__.py @@ -0,0 +1,4 @@ +""" +The `exp` module is a namespace that holds experimental features for the `hcloud-python` +library, breaking changes may occur within minor releases. +""" diff --git a/hcloud/exp/zone.py b/hcloud/exp/zone.py new file mode 100644 index 00000000..c8dd3d01 --- /dev/null +++ b/hcloud/exp/zone.py @@ -0,0 +1,40 @@ +""" +The `exp.zone` module is a namespace that holds experimental features for the `hcloud-python` +library, breaking changes may occur within minor releases. +""" + +from __future__ import annotations + +__all__ = [ + "is_txt_record_quoted", + "format_txt_record", +] + + +def is_txt_record_quoted(value: str) -> bool: + """ + Check whether a TXT record is already quoted. + + - hello world => false + - "hello world" => true + """ + return value.startswith('"') and value.endswith('"') + + +def format_txt_record(value: str) -> str: + """ + Format a TXT record by splitting it in quoted strings of 255 characters. + Existing quotes will be escaped. + + - hello world => "hello world" + - hello "world" => "hello \"world\"" + """ + value = value.replace('"', '\\"') + + parts = [] + for start in range(0, len(value), 255): + end = min(start + 255, len(value)) + parts.append('"' + value[start:end] + '"') + value = " ".join(parts) + + return value diff --git a/hcloud/firewalls/__init__.py b/hcloud/firewalls/__init__.py index 5205d766..f81ae0df 100644 --- a/hcloud/firewalls/__init__.py +++ b/hcloud/firewalls/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations -from .client import BoundFirewall, FirewallsClient, FirewallsPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundFirewall, FirewallsClient, FirewallsPageResult +from .domain import ( CreateFirewallResponse, Firewall, FirewallResource, @@ -9,3 +9,15 @@ FirewallResourceLabelSelector, FirewallRule, ) + +__all__ = [ + "BoundFirewall", + "CreateFirewallResponse", + "Firewall", + "FirewallResource", + "FirewallResourceAppliedToResources", + "FirewallResourceLabelSelector", + "FirewallRule", + "FirewallsClient", + "FirewallsPageResult", +] diff --git a/hcloud/firewalls/client.py b/hcloud/firewalls/client.py index fbcd1008..f314b68a 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -2,8 +2,15 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import ( CreateFirewallResponse, Firewall, @@ -17,12 +24,24 @@ from .._client import Client -class BoundFirewall(BoundModelBase, Firewall): +__all__ = [ + "BoundFirewall", + "FirewallsPageResult", + "FirewallsClient", +] + + +class BoundFirewall(BoundModelBase[Firewall], Firewall): _client: FirewallsClient model = Firewall - def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): + def __init__( + self, + client: FirewallsClient, + data: dict[str, Any], + complete: bool = True, + ): rules = data.get("rules", []) if rules: rules = [ @@ -52,7 +71,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): type=resource["type"], server=( BoundServer( - client._client.servers, + client._parent.servers, resource.get("server"), complete=False, ) @@ -68,7 +87,7 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): FirewallResource( type=firewall_resource["type"], server=BoundServer( - client._client.servers, + client._parent.servers, firewall_resource["server"], complete=False, ), @@ -92,40 +111,43 @@ def __init__(self, client: FirewallsClient, data: dict, complete: bool = True): def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Firewall. + """ + Returns a paginated list of Actions for a Firewall. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Firewall. - - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Firewall. - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -140,7 +162,7 @@ def update( New Name to set :return: :class:`BoundFirewall ` """ - return self._client.update(self, labels, name) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a Firewall. @@ -155,7 +177,7 @@ def set_rules(self, rules: list[FirewallRule]) -> list[BoundAction]: :return: List[:class:`BoundAction `] """ - return self._client.set_rules(self, rules) + return self._client.set_rules(self, rules=rules) def apply_to_resources( self, @@ -165,7 +187,7 @@ def apply_to_resources( :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - return self._client.apply_to_resources(self, resources) + return self._client.apply_to_resources(self, resources=resources) def remove_from_resources( self, @@ -175,16 +197,19 @@ def remove_from_resources( :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - return self._client.remove_from_resources(self, resources) + return self._client.remove_from_resources(self, resources=resources) class FirewallsPageResult(NamedTuple): firewalls: list[BoundFirewall] - meta: Meta | None + meta: Meta -class FirewallsClient(ClientEntityBase): - _client: Client +class FirewallsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/firewalls" actions: ResourceActionsClient """Firewalls scoped actions client @@ -194,64 +219,45 @@ class FirewallsClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/firewalls") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, firewall: Firewall | BoundFirewall, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Firewall. + """ + Returns a paginated list of Actions for a Firewall. - :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param firewall: Firewall to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - response = self._client.request( - url=f"/firewalls/{firewall.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{firewall.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, firewall: Firewall | BoundFirewall, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Firewall. - - :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Firewall. - :return: List[:class:`BoundAction `] + :param firewall: Firewall to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -266,7 +272,7 @@ def get_by_id(self, id: int) -> BoundFirewall: :param id: int :return: :class:`BoundFirewall ` """ - response = self._client.request(url=f"/firewalls/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFirewall(self, response["firewall"]) def get_list( @@ -303,7 +309,7 @@ def get_list( params["name"] = name if sort is not None: params["sort"] = sort - response = self._client.request(url="/firewalls", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) firewalls = [ BoundFirewall(self, firewall_data) for firewall_data in response["firewalls"] @@ -341,7 +347,7 @@ def get_by_name(self, name: str) -> BoundFirewall | None: Used to get Firewall by name. :return: :class:`BoundFirewall ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -373,12 +379,12 @@ def create( data.update({"apply_to": []}) for resource in resources: data["apply_to"].append(resource.to_payload()) - response = self._client.request(url="/firewalls", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") actions = [] if response.get("actions") is not None: actions = [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -409,7 +415,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/firewalls/{firewall.id}", + url=f"{self._base_url}/{firewall.id}", method="PUT", json=data, ) @@ -422,7 +428,7 @@ def delete(self, firewall: Firewall | BoundFirewall) -> bool: :return: boolean """ self._client.request( - url=f"/firewalls/{firewall.id}", + url=f"{self._base_url}/{firewall.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -443,12 +449,12 @@ def set_rules( for rule in rules: data["rules"].append(rule.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/set_rules", + url=f"{self._base_url}/{firewall.id}/actions/set_rules", method="POST", json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -467,12 +473,12 @@ def apply_to_resources( for resource in resources: data["apply_to"].append(resource.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/apply_to_resources", + url=f"{self._base_url}/{firewall.id}/actions/apply_to_resources", method="POST", json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] @@ -491,11 +497,11 @@ def remove_from_resources( for resource in resources: data["remove_from"].append(resource.to_payload()) response = self._client.request( - url=f"/firewalls/{firewall.id}/actions/remove_from_resources", + url=f"{self._base_url}/{firewall.id}/actions/remove_from_resources", method="POST", json=data, ) return [ - BoundAction(self._client.actions, action_data) + BoundAction(self._parent.actions, action_data) for action_data in response["actions"] ] diff --git a/hcloud/firewalls/domain.py b/hcloud/firewalls/domain.py index 41959ce1..76a9b596 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -2,8 +2,6 @@ from typing import TYPE_CHECKING, Any -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: @@ -12,6 +10,16 @@ from .client import BoundFirewall +__all__ = [ + "Firewall", + "FirewallRule", + "FirewallResource", + "FirewallResourceAppliedToResources", + "FirewallResourceLabelSelector", + "CreateFirewallResponse", +] + + class Firewall(BaseDomain, DomainIdentityMixin): """Firewall Domain @@ -29,7 +37,8 @@ class Firewall(BaseDomain, DomainIdentityMixin): Point in time when the image was created """ - __slots__ = ("id", "name", "labels", "rules", "applied_to", "created") + __api_properties__ = ("id", "name", "labels", "rules", "applied_to", "created") + __slots__ = __api_properties__ def __init__( self, @@ -45,7 +54,7 @@ def __init__( self.rules = rules self.applied_to = applied_to self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class FirewallRule(BaseDomain): @@ -66,7 +75,7 @@ class FirewallRule(BaseDomain): Short description of the firewall rule """ - __slots__ = ( + __api_properties__ = ( "direction", "port", "protocol", @@ -74,6 +83,7 @@ class FirewallRule(BaseDomain): "destination_ips", "description", ) + __slots__ = __api_properties__ DIRECTION_IN = "in" """Firewall Rule Direction In""" @@ -95,7 +105,7 @@ def __init__( self, direction: str, protocol: str, - source_ips: list[str], + source_ips: list[str] | None = None, port: str | None = None, destination_ips: list[str] | None = None, description: str | None = None, @@ -103,7 +113,7 @@ def __init__( self.direction = direction self.port = port self.protocol = protocol - self.source_ips = source_ips + self.source_ips = source_ips or [] self.destination_ips = destination_ips or [] self.description = description @@ -114,8 +124,9 @@ def to_payload(self) -> dict[str, Any]: payload: dict[str, Any] = { "direction": self.direction, "protocol": self.protocol, - "source_ips": self.source_ips, } + if len(self.source_ips) > 0: + payload["source_ips"] = self.source_ips if len(self.destination_ips) > 0: payload["destination_ips"] = self.destination_ips if self.port is not None: @@ -138,7 +149,8 @@ class FirewallResource(BaseDomain): applied to. """ - __slots__ = ("type", "server", "label_selector", "applied_to_resources") + __api_properties__ = ("type", "server", "label_selector", "applied_to_resources") + __slots__ = __api_properties__ TYPE_SERVER = "server" """Firewall Used By Type Server""" @@ -177,7 +189,8 @@ class FirewallResourceAppliedToResources(BaseDomain): :param server: Server the Firewall is applied to """ - __slots__ = ("type", "server") + __api_properties__ = ("type", "server") + __slots__ = __api_properties__ def __init__( self, @@ -194,6 +207,9 @@ class FirewallResourceLabelSelector(BaseDomain): :param selector: str Target label selector """ + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + def __init__(self, selector: str | None = None): self.selector = selector @@ -207,7 +223,8 @@ class CreateFirewallResponse(BaseDomain): The Action which shows the progress of the Firewall Creation """ - __slots__ = ("firewall", "actions") + __api_properties__ = ("firewall", "actions") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/floating_ips/__init__.py b/hcloud/floating_ips/__init__.py index 4e55bf5f..5ae08fca 100644 --- a/hcloud/floating_ips/__init__.py +++ b/hcloud/floating_ips/__init__.py @@ -1,8 +1,17 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundFloatingIP, FloatingIPsClient, FloatingIPsPageResult, ) -from .domain import CreateFloatingIPResponse, FloatingIP # noqa: F401 +from .domain import CreateFloatingIPResponse, FloatingIP, FloatingIPProtection + +__all__ = [ + "BoundFloatingIP", + "CreateFloatingIPResponse", + "FloatingIP", + "FloatingIPProtection", + "FloatingIPsClient", + "FloatingIPsPageResult", +] diff --git a/hcloud/floating_ips/client.py b/hcloud/floating_ips/client.py index 00600e48..88e7ea42 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -2,8 +2,15 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateFloatingIPResponse, FloatingIP @@ -12,65 +19,76 @@ from ..locations import Location from ..servers import BoundServer, Server +__all__ = [ + "BoundFloatingIP", + "FloatingIPsPageResult", + "FloatingIPsClient", +] -class BoundFloatingIP(BoundModelBase, FloatingIP): + +class BoundFloatingIP(BoundModelBase[FloatingIP], FloatingIP): _client: FloatingIPsClient model = FloatingIP - def __init__(self, client: FloatingIPsClient, data: dict, complete: bool = True): + def __init__( + self, + client: FloatingIPsClient, + data: dict[str, Any], + complete: bool = True, + ): # pylint: disable=import-outside-toplevel from ..servers import BoundServer server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) home_location = data.get("home_location") if home_location is not None: data["home_location"] = BoundLocation( - client._client.locations, home_location + client._parent.locations, home_location ) super().__init__(client, data, complete) def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Floating IP. + """ + Returns a paginated list of Actions for a Floating IP. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Floating IP. + """ + Returns all Actions for a Floating IP. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -88,7 +106,9 @@ def update( New Name to set :return: :class:`BoundFloatingIP ` """ - return self._client.update(self, description, labels, name) + return self._client.update( + self, description=description, labels=labels, name=name + ) def delete(self) -> bool: """Deletes a Floating IP. If it is currently assigned to a server it will automatically get unassigned. @@ -104,7 +124,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the Floating IP from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def assign(self, server: Server | BoundServer) -> BoundAction: """Assigns a Floating IP to a server. @@ -113,7 +133,7 @@ def assign(self, server: Server | BoundServer) -> BoundAction: Server the Floating IP shall be assigned to :return: :class:`BoundAction ` """ - return self._client.assign(self, server) + return self._client.assign(self, server=server) def unassign(self) -> BoundAction: """Unassigns a Floating IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -131,16 +151,19 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) class FloatingIPsPageResult(NamedTuple): floating_ips: list[BoundFloatingIP] - meta: Meta | None + meta: Meta -class FloatingIPsClient(ClientEntityBase): - _client: Client +class FloatingIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/floating_ips" actions: ResourceActionsClient """Floating IPs scoped actions client @@ -150,64 +173,45 @@ class FloatingIPsClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/floating_ips") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, floating_ip: FloatingIP | BoundFloatingIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Floating IP. + """ + Returns a paginated list of Actions for a Floating IP. - :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param floating_ip: Floating IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{floating_ip.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, floating_ip: FloatingIP | BoundFloatingIP, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Floating IP. - - :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` + """ + Returns all Actions for a Floating IP. - :return: List[:class:`BoundAction `] + :param floating_ip: Floating IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -222,7 +226,7 @@ def get_by_id(self, id: int) -> BoundFloatingIP: :param id: int :return: :class:`BoundFloatingIP ` """ - response = self._client.request(url=f"/floating_ips/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFloatingIP(self, response["floating_ip"]) def get_list( @@ -255,9 +259,7 @@ def get_list( if name is not None: params["name"] = name - response = self._client.request( - url="/floating_ips", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) floating_ips = [ BoundFloatingIP(self, floating_ip_data) for floating_ip_data in response["floating_ips"] @@ -287,13 +289,13 @@ def get_by_name(self, name: str) -> BoundFloatingIP | None: Used to get Floating IP by name. :return: :class:`BoundFloatingIP ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, type: str, description: str | None = None, - labels: str | None = None, + labels: dict[str, str] | None = None, home_location: Location | BoundLocation | None = None, server: Server | BoundServer | None = None, name: str | None = None, @@ -325,11 +327,11 @@ def create( if name is not None: data["name"] = name - response = self._client.request(url="/floating_ips", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreateFloatingIPResponse( floating_ip=BoundFloatingIP(self, response["floating_ip"]), action=action @@ -363,7 +365,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/floating_ips/{floating_ip.id}", + url=f"{self._base_url}/{floating_ip.id}", method="PUT", json=data, ) @@ -376,7 +378,7 @@ def delete(self, floating_ip: FloatingIP | BoundFloatingIP) -> bool: :return: boolean """ self._client.request( - url=f"/floating_ips/{floating_ip.id}", + url=f"{self._base_url}/{floating_ip.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -399,11 +401,11 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/change_protection", + url=f"{self._base_url}/{floating_ip.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -418,11 +420,11 @@ def assign( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/assign", + url=f"{self._base_url}/{floating_ip.id}/actions/assign", method="POST", json={"server": server.id}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: """Unassigns a Floating IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -431,10 +433,10 @@ def unassign(self, floating_ip: FloatingIP | BoundFloatingIP) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/unassign", + url=f"{self._base_url}/{floating_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -452,8 +454,8 @@ def change_dns_ptr( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/floating_ips/{floating_ip.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{floating_ip.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/floating_ips/domain.py b/hcloud/floating_ips/domain.py index 78b701fe..3946da7b 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -1,18 +1,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction from ..locations import BoundLocation + from ..rdns import DNSPtr from ..servers import BoundServer from .client import BoundFloatingIP +__all__ = [ + "FloatingIP", + "FloatingIPProtection", + "CreateFloatingIPResponse", +] + + class FloatingIP(BaseDomain, DomainIdentityMixin): """Floating IP Domain @@ -42,7 +48,7 @@ class FloatingIP(BaseDomain, DomainIdentityMixin): Name of the Floating IP """ - __slots__ = ( + __api_properties__ = ( "id", "type", "description", @@ -56,6 +62,7 @@ class FloatingIP(BaseDomain, DomainIdentityMixin): "name", "created", ) + __slots__ = __api_properties__ def __init__( self, @@ -64,10 +71,10 @@ def __init__( description: str | None = None, ip: str | None = None, server: BoundServer | None = None, - dns_ptr: list[dict] | None = None, + dns_ptr: list[DNSPtr] | None = None, home_location: BoundLocation | None = None, blocked: bool | None = None, - protection: dict | None = None, + protection: FloatingIPProtection | None = None, labels: dict[str, str] | None = None, created: str | None = None, name: str | None = None, @@ -82,10 +89,14 @@ def __init__( self.blocked = blocked self.protection = protection self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.name = name +class FloatingIPProtection(TypedDict): + delete: bool + + class CreateFloatingIPResponse(BaseDomain): """Create Floating IP Response Domain @@ -95,7 +106,8 @@ class CreateFloatingIPResponse(BaseDomain): The Action which shows the progress of the Floating IP Creation """ - __slots__ = ("floating_ip", "action") + __api_properties__ = ("floating_ip", "action") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/hcloud.py b/hcloud/hcloud.py deleted file mode 100644 index 9de1cfe5..00000000 --- a/hcloud/hcloud.py +++ /dev/null @@ -1,12 +0,0 @@ -from __future__ import annotations - -import warnings - -warnings.warn( - "The 'hcloud.hcloud' module is deprecated, please import from the 'hcloud' module instead (e.g. 'from hcloud import Client').", - DeprecationWarning, - stacklevel=2, -) - -# pylint: disable=wildcard-import,wrong-import-position,unused-wildcard-import -from ._client import * # noqa diff --git a/hcloud/helpers/__init__.py b/hcloud/helpers/__init__.py index b6a4cd73..e8ec1dcb 100644 --- a/hcloud/helpers/__init__.py +++ b/hcloud/helpers/__init__.py @@ -1,3 +1,7 @@ from __future__ import annotations -from .labels import LabelValidator # noqa: F401 +from .labels import LabelValidator + +__all__ = [ + "LabelValidator", +] diff --git a/hcloud/helpers/labels.py b/hcloud/helpers/labels.py index 36041578..78f43142 100644 --- a/hcloud/helpers/labels.py +++ b/hcloud/helpers/labels.py @@ -2,6 +2,10 @@ import re +__all__ = [ + "LabelValidator", +] + class LabelValidator: KEY_REGEX = re.compile( diff --git a/hcloud/images/__init__.py b/hcloud/images/__init__.py index 78cb6868..c299722c 100644 --- a/hcloud/images/__init__.py +++ b/hcloud/images/__init__.py @@ -1,4 +1,13 @@ from __future__ import annotations -from .client import BoundImage, ImagesClient, ImagesPageResult # noqa: F401 -from .domain import CreateImageResponse, Image # noqa: F401 +from .client import BoundImage, ImagesClient, ImagesPageResult +from .domain import CreateImageResponse, Image, ImageProtection + +__all__ = [ + "BoundImage", + "CreateImageResponse", + "Image", + "ImageProtection", + "ImagesClient", + "ImagesPageResult", +] diff --git a/hcloud/images/client.py b/hcloud/images/client.py index a7d41e01..2566a96f 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -3,73 +3,93 @@ import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Image if TYPE_CHECKING: from .._client import Client -class BoundImage(BoundModelBase, Image): +__all__ = [ + "BoundImage", + "ImagesPageResult", + "ImagesClient", +] + + +class BoundImage(BoundModelBase[Image], Image): _client: ImagesClient model = Image - def __init__(self, client: ImagesClient, data: dict): + def __init__( + self, + client: ImagesClient, + data: dict[str, Any], + ): # pylint: disable=import-outside-toplevel from ..servers import BoundServer created_from = data.get("created_from") if created_from is not None: data["created_from"] = BoundServer( - client._client.servers, created_from, complete=False + client._parent.servers, created_from, complete=False ) bound_to = data.get("bound_to") if bound_to is not None: data["bound_to"] = BoundServer( - client._client.servers, {"id": bound_to}, complete=False + client._parent.servers, {"id": bound_to}, complete=False ) super().__init__(client, data) def get_actions_list( self, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, - status: list[str] | None = None, ) -> ActionsPageResult: - """Returns a list of action objects for the image. + """ + Returns a paginated list of Actions for a Image. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ return self._client.get_actions_list( - self, sort=sort, page=page, per_page=per_page, status=status + self, + sort=sort, + page=page, + per_page=per_page, + status=status, ) def get_actions( self, - sort: list[str] | None = None, - status: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for the image. + """ + Returns all Actions for a Image. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status=status, sort=sort) + return self._client.get_actions( + self, + status=status, + sort=sort, + ) def update( self, @@ -88,7 +108,9 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundImage ` """ - return self._client.update(self, description, type, labels) + return self._client.update( + self, description=description, type=type, labels=labels + ) def delete(self) -> bool: """Deletes an Image. Only images of type snapshot and backup can be deleted. @@ -104,16 +126,19 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the snapshot from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) class ImagesPageResult(NamedTuple): images: list[BoundImage] - meta: Meta | None + meta: Meta -class ImagesClient(ClientEntityBase): - _client: Client +class ImagesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/images" actions: ResourceActionsClient """Images scoped actions client @@ -123,69 +148,51 @@ class ImagesClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/images") + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, image: Image | BoundImage, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, - status: list[str] | None = None, ) -> ActionsPageResult: - """Returns a list of action objects for an image. + """ + Returns a paginated list of Actions for a Image. - :param image: :class:`BoundImage ` or :class:`Image ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param image: Image to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - params: dict[str, Any] = {} - if sort is not None: - params["sort"] = sort - if status is not None: - params["status"] = status - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page - response = self._client.request( - url=f"/images/{image.id}/actions", - method="GET", - params=params, + return self._get_actions_list( + f"{self._base_url}/{image.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, image: Image | BoundImage, - sort: list[str] | None = None, - status: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for an image. + """ + Returns all Actions for a Image. - :param image: :class:`BoundImage ` or :class:`Image ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `command` `status` `progress` `started` `finished` . You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default) - :return: List[:class:`BoundAction `] + :param image: Image to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, image, - sort=sort, status=status, + sort=sort, ) def get_by_id(self, id: int) -> BoundImage: @@ -194,7 +201,7 @@ def get_by_id(self, id: int) -> BoundImage: :param id: int :return: :class:`BoundImage BoundImage | None: DeprecationWarning, stacklevel=2, ) - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_by_name_and_architecture( self, @@ -339,6 +346,7 @@ def get_by_name_and_architecture( :return: :class:`BoundImage ` """ return self._get_first_by( + self.get_list, name=name, architecture=[architecture], include_deprecated=include_deprecated, @@ -371,7 +379,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/images/{image.id}", method="PUT", json=data + url=f"{self._base_url}/{image.id}", method="PUT", json=data ) return BoundImage(self, response["image"]) @@ -381,7 +389,7 @@ def delete(self, image: Image | BoundImage) -> bool: :param :class:`BoundImage ` or :class:`Image ` :return: bool """ - self._client.request(url=f"/images/{image.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{image.id}", method="DELETE") # Return allays true, because the API does not return an action for it. When an error occurs a APIException will be raised return True @@ -402,8 +410,8 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/images/{image.id}/actions/change_protection", + url=f"{self._base_url}/{image.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index 37d6d9f7..915ace64 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -12,6 +10,13 @@ from .client import BoundImage +__all__ = [ + "Image", + "ImageProtection", + "CreateImageResponse", +] + + class Image(BaseDomain, DomainIdentityMixin): """Image Domain @@ -51,7 +56,7 @@ class Image(BaseDomain, DomainIdentityMixin): User-defined labels (key-value pairs) """ - __slots__ = ( + __api_properties__ = ( "id", "name", "type", @@ -70,6 +75,7 @@ class Image(BaseDomain, DomainIdentityMixin): "created", "deprecated", ) + __slots__ = __api_properties__ # pylint: disable=too-many-locals def __init__( @@ -88,18 +94,18 @@ def __init__( architecture: str | None = None, rapid_deploy: bool | None = None, created_from: Server | BoundServer | None = None, - protection: dict | None = None, + protection: ImageProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, ): self.id = id self.name = name self.type = type - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.description = description self.image_size = image_size self.disk_size = disk_size - self.deprecated = isoparse(deprecated) if deprecated else None + self.deprecated = self._parse_datetime(deprecated) self.bound_to = bound_to self.os_flavor = os_flavor self.os_version = os_version @@ -111,6 +117,10 @@ def __init__( self.status = status +class ImageProtection(TypedDict): + delete: bool + + class CreateImageResponse(BaseDomain): """Create Image Response Domain @@ -120,7 +130,8 @@ class CreateImageResponse(BaseDomain): The Action which shows the progress of the Floating IP Creation """ - __slots__ = ("action", "image") + __api_properties__ = ("action", "image") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/isos/__init__.py b/hcloud/isos/__init__.py index 0d5e38fa..91f137c3 100644 --- a/hcloud/isos/__init__.py +++ b/hcloud/isos/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundIso, IsosClient, IsosPageResult # noqa: F401 -from .domain import Iso # noqa: F401 +from .client import BoundIso, IsosClient, IsosPageResult +from .domain import Iso + +__all__ = [ + "BoundIso", + "Iso", + "IsosClient", + "IsosPageResult", +] diff --git a/hcloud/isos/client.py b/hcloud/isos/client.py index cc46af7f..b9c3a5a0 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -1,16 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple -from warnings import warn +from typing import Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Iso -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundIso", + "IsosPageResult", + "IsosClient", +] -class BoundIso(BoundModelBase, Iso): +class BoundIso(BoundModelBase[Iso], Iso): _client: IsosClient model = Iso @@ -18,11 +20,11 @@ class BoundIso(BoundModelBase, Iso): class IsosPageResult(NamedTuple): isos: list[BoundIso] - meta: Meta | None + meta: Meta -class IsosClient(ClientEntityBase): - _client: Client +class IsosClient(ResourceClientBase): + _base_url = "/isos" def get_by_id(self, id: int) -> BoundIso: """Get a specific ISO by its id @@ -30,14 +32,13 @@ def get_by_id(self, id: int) -> BoundIso: :param id: int :return: :class:`BoundIso ` """ - response = self._client.request(url=f"/isos/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundIso(self, response["iso"]) def get_list( self, name: str | None = None, architecture: list[str] | None = None, - include_wildcard_architecture: bool | None = None, include_architecture_wildcard: bool | None = None, page: int | None = None, per_page: int | None = None, @@ -48,8 +49,6 @@ def get_list( Can be used to filter ISOs by their name. :param architecture: List[str] (optional) Can be used to filter ISOs by their architecture. Choices: x86 arm - :param include_wildcard_architecture: bool (optional) - Deprecated, please use `include_architecture_wildcard` instead. :param include_architecture_wildcard: bool (optional) Custom ISOs do not have an architecture set. You must also set this flag to True if you are filtering by architecture and also want custom ISOs. @@ -59,14 +58,6 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundIso `], :class:`Meta `) """ - - if include_wildcard_architecture is not None: - warn( - "The `include_wildcard_architecture` argument is deprecated, please use the `include_architecture_wildcard` argument instead.", - DeprecationWarning, - ) - include_architecture_wildcard = include_wildcard_architecture - params: dict[str, Any] = {} if name is not None: params["name"] = name @@ -79,7 +70,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/isos", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) isos = [BoundIso(self, iso_data) for iso_data in response["isos"]] return IsosPageResult(isos, Meta.parse_meta(response)) @@ -87,7 +78,6 @@ def get_all( self, name: str | None = None, architecture: list[str] | None = None, - include_wildcard_architecture: bool | None = None, include_architecture_wildcard: bool | None = None, ) -> list[BoundIso]: """Get all ISOs @@ -96,21 +86,11 @@ def get_all( Can be used to filter ISOs by their name. :param architecture: List[str] (optional) Can be used to filter ISOs by their architecture. Choices: x86 arm - :param include_wildcard_architecture: bool (optional) - Deprecated, please use `include_architecture_wildcard` instead. :param include_architecture_wildcard: bool (optional) Custom ISOs do not have an architecture set. You must also set this flag to True if you are filtering by architecture and also want custom ISOs. :return: List[:class:`BoundIso `] """ - - if include_wildcard_architecture is not None: - warn( - "The `include_wildcard_architecture` argument is deprecated, please use the `include_architecture_wildcard` argument instead.", - DeprecationWarning, - ) - include_architecture_wildcard = include_wildcard_architecture - return self._iter_pages( self.get_list, name=name, @@ -125,4 +105,4 @@ def get_by_name(self, name: str) -> BoundIso | None: Used to get iso by name. :return: :class:`BoundIso ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/isos/domain.py b/hcloud/isos/domain.py index b2f4d30c..5fd50c6e 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -1,11 +1,16 @@ from __future__ import annotations from datetime import datetime +from typing import Any from warnings import warn from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo +__all__ = [ + "Iso", +] + class Iso(BaseDomain, DomainIdentityMixin): """Iso Domain @@ -27,7 +32,7 @@ class Iso(BaseDomain, DomainIdentityMixin): deprecated. If it has a value, it is considered deprecated. """ - __slots__ = ( + __api_properties__ = ( "id", "name", "type", @@ -35,6 +40,7 @@ class Iso(BaseDomain, DomainIdentityMixin): "description", "deprecation", ) + __slots__ = __api_properties__ def __init__( self, @@ -44,7 +50,7 @@ def __init__( architecture: str | None = None, description: str | None = None, deprecated: str | None = None, # pylint: disable=unused-argument - deprecation: dict | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -66,4 +72,4 @@ def deprecated(self) -> datetime | None: ) if self.deprecation is None: return None - return self.deprecation.unavailable_after + return self.deprecation.unavailable_after # type: ignore[no-any-return] diff --git a/hcloud/load_balancer_types/__init__.py b/hcloud/load_balancer_types/__init__.py index fa1dc33c..1b147bf7 100644 --- a/hcloud/load_balancer_types/__init__.py +++ b/hcloud/load_balancer_types/__init__.py @@ -1,8 +1,15 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundLoadBalancerType, LoadBalancerTypesClient, LoadBalancerTypesPageResult, ) -from .domain import LoadBalancerType # noqa: F401 +from .domain import LoadBalancerType + +__all__ = [ + "BoundLoadBalancerType", + "LoadBalancerType", + "LoadBalancerTypesClient", + "LoadBalancerTypesPageResult", +] diff --git a/hcloud/load_balancer_types/client.py b/hcloud/load_balancer_types/client.py index 9a83dc70..87d44e2f 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -1,15 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import LoadBalancerType -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundLoadBalancerType", + "LoadBalancerTypesPageResult", + "LoadBalancerTypesClient", +] -class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): +class BoundLoadBalancerType(BoundModelBase[LoadBalancerType], LoadBalancerType): _client: LoadBalancerTypesClient model = LoadBalancerType @@ -17,11 +20,11 @@ class BoundLoadBalancerType(BoundModelBase, LoadBalancerType): class LoadBalancerTypesPageResult(NamedTuple): load_balancer_types: list[BoundLoadBalancerType] - meta: Meta | None + meta: Meta -class LoadBalancerTypesClient(ClientEntityBase): - _client: Client +class LoadBalancerTypesClient(ResourceClientBase): + _base_url = "/load_balancer_types" def get_by_id(self, id: int) -> BoundLoadBalancerType: """Returns a specific Load Balancer Type. @@ -30,7 +33,7 @@ def get_by_id(self, id: int) -> BoundLoadBalancerType: :return: :class:`BoundLoadBalancerType ` """ response = self._client.request( - url=f"/load_balancer_types/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancerType(self, response["load_balancer_type"]) @@ -59,9 +62,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/load_balancer_types", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) load_balancer_types = [ BoundLoadBalancerType(self, load_balancer_type_data) for load_balancer_type_data in response["load_balancer_types"] @@ -86,4 +87,4 @@ def get_by_name(self, name: str) -> BoundLoadBalancerType | None: Used to get Load Balancer type by name. :return: :class:`BoundLoadBalancerType ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/load_balancer_types/domain.py b/hcloud/load_balancer_types/domain.py index 35719dba..7814a4f4 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -1,6 +1,13 @@ from __future__ import annotations +from typing import Any + from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + +__all__ = [ + "LoadBalancerType", +] class LoadBalancerType(BaseDomain, DomainIdentityMixin): @@ -20,12 +27,12 @@ class LoadBalancerType(BaseDomain, DomainIdentityMixin): Max amount of targets the Load Balancer can handle :param max_assigned_certificates: int Max amount of certificates the Load Balancer can serve - :param prices: Dict + :param prices: List of dict Prices in different locations - + :param deprecation: Define if and when the Load Balancer Type is deprecated. """ - __slots__ = ( + __api_properties__ = ( "id", "name", "description", @@ -34,7 +41,9 @@ class LoadBalancerType(BaseDomain, DomainIdentityMixin): "max_targets", "max_assigned_certificates", "prices", + "deprecation", ) + __slots__ = __api_properties__ def __init__( self, @@ -45,7 +54,8 @@ def __init__( max_services: int | None = None, max_targets: int | None = None, max_assigned_certificates: int | None = None, - prices: dict | None = None, + prices: list[dict[str, Any]] | None = None, + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name @@ -55,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 4bfd7994..9b07d026 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -1,11 +1,11 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundLoadBalancer, LoadBalancersClient, LoadBalancersPageResult, ) -from .domain import ( # noqa: F401 +from .domain import ( CreateLoadBalancerResponse, GetMetricsResponse, IPv4Address, @@ -14,12 +14,40 @@ LoadBalancerAlgorithm, LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, + LoadBalancerProtection, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetHealthStatus, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + MetricsType, PrivateNet, PublicNetwork, ) + +__all__ = [ + "BoundLoadBalancer", + "CreateLoadBalancerResponse", + "GetMetricsResponse", + "IPv4Address", + "IPv6Network", + "LoadBalancer", + "LoadBalancerProtection", + "LoadBalancerAlgorithm", + "LoadBalancerHealtCheckHttp", + "LoadBalancerHealthCheckHttp", + "LoadBalancerHealthCheck", + "LoadBalancerService", + "LoadBalancerServiceHttp", + "LoadBalancerTarget", + "LoadBalancerTargetHealthStatus", + "LoadBalancerTargetIP", + "LoadBalancerTargetLabelSelector", + "LoadBalancersClient", + "LoadBalancersPageResult", + "PrivateNet", + "PublicNetwork", + "MetricsType", +] diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index e0635eec..6dd4672e 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -5,9 +5,16 @@ from dateutil.parser import isoparse -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin from ..certificates import BoundCertificate -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation from ..metrics import Metrics @@ -20,8 +27,8 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, - LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, @@ -40,13 +47,25 @@ from ..networks import Network -class BoundLoadBalancer(BoundModelBase, LoadBalancer): +__all__ = [ + "BoundLoadBalancer", + "LoadBalancersPageResult", + "LoadBalancersClient", +] + + +class BoundLoadBalancer(BoundModelBase[LoadBalancer], LoadBalancer): _client: LoadBalancersClient model = LoadBalancer # pylint: disable=too-many-branches,too-many-locals - def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = True): + def __init__( + self, + client: LoadBalancersClient, + data: dict[str, Any], + complete: bool = True, + ): algorithm = data.get("algorithm") if algorithm: data["algorithm"] = LoadBalancerAlgorithm(type=algorithm["type"]) @@ -64,7 +83,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -74,36 +93,51 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru ] 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._client.servers, data=target["server"], complete=False - ) - tmp_target.use_private_ip = target["use_private_ip"] - elif target["type"] == "label_selector": - tmp_target.label_selector = LoadBalancerTargetLabelSelector( - selector=target["label_selector"]["selector"] + def _load_balancer_targets( + raw_targets: list[dict[str, Any]], + ) -> list[LoadBalancerTarget]: + return [_load_balancer_target(raw_target) for raw_target in raw_targets] + + def _load_balancer_target( + raw_target: dict[str, Any], + ) -> LoadBalancerTarget: + result = LoadBalancerTarget(type=raw_target["type"]) + + if raw_target["type"] == "ip": + result.ip = LoadBalancerTargetIP( + ip=raw_target["ip"]["ip"], + ) + + elif raw_target["type"] == "server": + result.server = BoundServer( + client._parent.servers, # pylint: disable=protected-access + data=raw_target["server"], + complete=False, + ) + result.use_private_ip = raw_target["use_private_ip"] + + elif raw_target["type"] == "label_selector": + result.label_selector = LoadBalancerTargetLabelSelector( + selector=raw_target["label_selector"]["selector"] + ) + result.use_private_ip = raw_target["use_private_ip"] + + if (raw_nested_targets := raw_target.get("targets")) is not None: + result.targets = _load_balancer_targets(raw_nested_targets) + + if (raw_health_status := raw_target.get("health_status")) is not None: + result.health_status = [ + LoadBalancerTargetHealthStatus( + listen_port=item["listen_port"], + status=item["status"], ) - tmp_target.use_private_ip = target["use_private_ip"] - elif target["type"] == "ip": - tmp_target.ip = LoadBalancerTargetIP(ip=target["ip"]["ip"]) - - target_health_status = target.get("health_status") - if target_health_status is not None: - tmp_target.health_status = [ - LoadBalancerTargetHealthStatus( - listen_port=target_health_status_item["listen_port"], - status=target_health_status_item["status"], - ) - for target_health_status_item in target_health_status - ] + for item in raw_health_status + ] + + return result - tmp_targets.append(tmp_target) - data["targets"] = tmp_targets + if (raw_targets := data.get("targets")) is not None: + data["targets"] = _load_balancer_targets(raw_targets) services = data.get("services") if services: @@ -121,10 +155,11 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru 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( - client._client.certificates, + client._parent.certificates, {"id": certificate}, complete=False, ) @@ -139,7 +174,7 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru timeout=service["health_check"]["timeout"], ) if tmp_service.health_check.protocol != "tcp": - tmp_service.health_check.http = LoadBalancerHealtCheckHttp( + tmp_service.health_check.http = LoadBalancerHealthCheckHttp( domain=service["health_check"]["http"]["domain"], path=service["health_check"]["http"]["path"], response=service["health_check"]["http"]["response"], @@ -152,12 +187,12 @@ def __init__(self, client: LoadBalancersClient, data: dict, complete: bool = Tru load_balancer_type = data.get("load_balancer_type") if load_balancer_type is not None: data["load_balancer_type"] = BoundLoadBalancerType( - client._client.load_balancer_types, load_balancer_type + client._parent.load_balancer_types, load_balancer_type ) location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) super().__init__(client, data, complete) @@ -174,7 +209,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundLoadBalancer ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a Load Balancer. @@ -207,39 +242,39 @@ def get_metrics( def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Load Balancer. + """ + Returns a paginated list of Actions for a Load Balancer. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Load Balancer. + """ + Returns all Actions for a Load Balancer. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def add_service(self, service: LoadBalancerService) -> BoundAction: """Adds a service to a Load Balancer. @@ -266,7 +301,7 @@ def delete_service(self, service: LoadBalancerService) -> BoundAction: The LoadBalancerService you want to delete from the Load Balancer :return: :class:`BoundAction ` """ - return self._client.delete_service(self, service) + return self._client.delete_service(self, service=service) def add_target(self, target: LoadBalancerTarget) -> BoundAction: """Adds a target to a Load Balancer. @@ -275,7 +310,7 @@ def add_target(self, target: LoadBalancerTarget) -> BoundAction: The LoadBalancerTarget you want to add to the Load Balancer :return: :class:`BoundAction ` """ - return self._client.add_target(self, target) + return self._client.add_target(self, target=target) def remove_target(self, target: LoadBalancerTarget) -> BoundAction: """Removes a target from a Load Balancer. @@ -284,7 +319,7 @@ def remove_target(self, target: LoadBalancerTarget) -> BoundAction: The LoadBalancerTarget you want to remove from the Load Balancer :return: :class:`BoundAction ` """ - return self._client.remove_target(self, target) + return self._client.remove_target(self, target=target) def change_algorithm(self, algorithm: LoadBalancerAlgorithm) -> BoundAction: """Changes the algorithm used by the Load Balancer @@ -293,7 +328,7 @@ def change_algorithm(self, algorithm: LoadBalancerAlgorithm) -> BoundAction: The LoadBalancerAlgorithm you want to use :return: :class:`BoundAction ` """ - return self._client.change_algorithm(self, algorithm) + return self._client.change_algorithm(self, algorithm=algorithm) def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to the public IPs (IPv4 and IPv6) of this Load Balancer. @@ -304,7 +339,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) def change_protection(self, delete: bool) -> BoundAction: """Changes the protection configuration of a Load Balancer. @@ -313,21 +348,29 @@ def change_protection(self, delete: bool) -> BoundAction: If True, prevents the Load Balancer from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def attach_to_network( self, network: Network | BoundNetwork, ip: str | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a Load Balancer to a Network :param network: :class:`BoundNetwork ` or :class:`Network ` :param ip: str IP to request to be assigned to this Load Balancer + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip) + return self._client.attach_to_network( + self, + network=network, + ip=ip, + ip_range=ip_range, + ) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a Load Balancer from a Network. @@ -335,7 +378,7 @@ def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.detach_from_network(self, network) + return self._client.detach_from_network(self, network=network) def enable_public_interface(self) -> BoundAction: """Enables the public interface of a Load Balancer. @@ -361,16 +404,19 @@ def change_type( Load Balancer type the Load Balancer should migrate to :return: :class:`BoundAction ` """ - return self._client.change_type(self, load_balancer_type) + return self._client.change_type(self, load_balancer_type=load_balancer_type) class LoadBalancersPageResult(NamedTuple): load_balancers: list[BoundLoadBalancer] - meta: Meta | None + meta: Meta -class LoadBalancersClient(ClientEntityBase): - _client: Client +class LoadBalancersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/load_balancers" actions: ResourceActionsClient """Load Balancers scoped actions client @@ -380,7 +426,7 @@ class LoadBalancersClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/load_balancers") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundLoadBalancer: """Get a specific Load Balancer @@ -389,7 +435,7 @@ def get_by_id(self, id: int) -> BoundLoadBalancer: :return: :class:`BoundLoadBalancer ` """ response = self._client.request( - url=f"/load_balancers/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancer(self, response["load_balancer"]) @@ -423,9 +469,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/load_balancers", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) load_balancers = [ BoundLoadBalancer(self, load_balancer_data) @@ -455,7 +499,7 @@ def get_by_name(self, name: str) -> BoundLoadBalancer | None: Used to get Load Balancer by name. :return: :class:`BoundLoadBalancer ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -515,11 +559,11 @@ def create( if location is not None: data["location"] = location.id_or_name - response = self._client.request(url="/load_balancers", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return CreateLoadBalancerResponse( load_balancer=BoundLoadBalancer(self, response["load_balancer"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), ) def update( @@ -543,7 +587,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}", + url=f"{self._base_url}/{load_balancer.id}", method="PUT", json=data, ) @@ -556,7 +600,7 @@ def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: :return: boolean """ self._client.request( - url=f"/load_balancers/{load_balancer.id}", + url=f"{self._base_url}/{load_balancer.id}", method="DELETE", ) return True @@ -593,7 +637,7 @@ def get_metrics( params["step"] = step response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/metrics", + url=f"{self._base_url}/{load_balancer.id}/metrics", method="GET", params=params, ) @@ -604,59 +648,40 @@ def get_metrics( def get_actions_list( self, load_balancer: LoadBalancer | BoundLoadBalancer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a Load Balancer. - - :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Load Balancer. - response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions", - method="GET", - params=params, + :param load_balancer: Load Balancer to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{load_balancer.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, load_balancer: LoadBalancer | BoundLoadBalancer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a Load Balancer. + """ + Returns all Actions for a Load Balancer. - :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param load_balancer: Load Balancer to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -680,11 +705,11 @@ def add_service( data: dict[str, Any] = service.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/add_service", + url=f"{self._base_url}/{load_balancer.id}/actions/add_service", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def update_service( self, @@ -700,11 +725,11 @@ def update_service( """ data: dict[str, Any] = service.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/update_service", + url=f"{self._base_url}/{load_balancer.id}/actions/update_service", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_service( self, @@ -721,11 +746,11 @@ def delete_service( data: dict[str, Any] = {"listen_port": service.listen_port} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/delete_service", + url=f"{self._base_url}/{load_balancer.id}/actions/delete_service", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_target( self, @@ -742,11 +767,11 @@ def add_target( data: dict[str, Any] = target.to_payload() response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/add_target", + url=f"{self._base_url}/{load_balancer.id}/actions/add_target", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_target( self, @@ -765,11 +790,11 @@ def remove_target( data.pop("use_private_ip", None) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/remove_target", + url=f"{self._base_url}/{load_balancer.id}/actions/remove_target", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_algorithm( self, @@ -786,11 +811,11 @@ def change_algorithm( data: dict[str, Any] = {"type": algorithm.type} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_algorithm", + url=f"{self._base_url}/{load_balancer.id}/actions/change_algorithm", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -808,11 +833,11 @@ def change_dns_ptr( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{load_balancer.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -831,17 +856,18 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_protection", + url=f"{self._base_url}/{load_balancer.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_to_network( self, load_balancer: LoadBalancer | BoundLoadBalancer, network: Network | BoundNetwork, ip: str | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attach a Load Balancer to a Network. @@ -849,18 +875,22 @@ def attach_to_network( :param network: :class:`BoundNetwork ` or :class:`Network ` :param ip: str IP to request to be assigned to this Load Balancer + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ data: dict[str, Any] = {"network": network.id} if ip is not None: data.update({"ip": ip}) + if ip_range is not None: + data.update({"ip_range": ip_range}) response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/attach_to_network", + url=f"{self._base_url}/{load_balancer.id}/actions/attach_to_network", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -875,11 +905,11 @@ def detach_from_network( """ data: dict[str, Any] = {"network": network.id} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/detach_from_network", + url=f"{self._base_url}/{load_balancer.id}/actions/detach_from_network", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_public_interface( self, @@ -893,10 +923,10 @@ def enable_public_interface( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/enable_public_interface", + url=f"{self._base_url}/{load_balancer.id}/actions/enable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_public_interface( self, @@ -910,10 +940,10 @@ def disable_public_interface( """ response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/disable_public_interface", + url=f"{self._base_url}/{load_balancer.id}/actions/disable_public_interface", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_type( self, @@ -929,8 +959,8 @@ def change_type( """ data: dict[str, Any] = {"load_balancer_type": load_balancer_type.id_or_name} response = self._client.request( - url=f"/load_balancers/{load_balancer.id}/actions/change_type", + url=f"{self._base_url}/{load_balancer.id}/actions/change_type", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index fdf211a2..7561af2a 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal - -from dateutil.parser import isoparse +import warnings +from typing import TYPE_CHECKING, Any, Literal, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -12,11 +11,34 @@ from ..load_balancer_types import BoundLoadBalancerType from ..locations import BoundLocation from ..metrics import Metrics - from ..networks import BoundNetwork + from ..networks import BoundNetwork, Network from ..servers import BoundServer from .client import BoundLoadBalancer +__all__ = [ + "LoadBalancer", + "LoadBalancerProtection", + "LoadBalancerService", + "LoadBalancerServiceHttp", + "LoadBalancerHealthCheck", + "LoadBalancerHealthCheckHttp", + "LoadBalancerHealtCheckHttp", + "LoadBalancerTarget", + "LoadBalancerTargetHealthStatus", + "LoadBalancerTargetLabelSelector", + "LoadBalancerTargetIP", + "LoadBalancerAlgorithm", + "PublicNetwork", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "CreateLoadBalancerResponse", + "GetMetricsResponse", + "MetricsType", +] + + class LoadBalancer(BaseDomain, DomainIdentityMixin): """LoadBalancer Domain @@ -52,7 +74,7 @@ class LoadBalancer(BaseDomain, DomainIdentityMixin): Free Traffic for the current billing period in bytes """ - __slots__ = ( + __api_properties__ = ( "id", "name", "public_net", @@ -69,6 +91,7 @@ class LoadBalancer(BaseDomain, DomainIdentityMixin): "ingoing_traffic", "included_traffic", ) + __slots__ = __api_properties__ # pylint: disable=too-many-locals def __init__( @@ -81,7 +104,7 @@ def __init__( algorithm: LoadBalancerAlgorithm | None = None, services: list[LoadBalancerService] | None = None, load_balancer_type: BoundLoadBalancerType | None = None, - protection: dict | None = None, + protection: LoadBalancerProtection | None = None, labels: dict[str, str] | None = None, targets: list[LoadBalancerTarget] | None = None, created: str | None = None, @@ -91,7 +114,7 @@ def __init__( ): self.id = id self.name = name - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.public_net = public_net self.private_net = private_net self.location = location @@ -105,6 +128,20 @@ def __init__( self.ingoing_traffic = ingoing_traffic self.included_traffic = included_traffic + def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: + """ + Returns the load balancer's network attachment information in the given Network, + and None if no attachment was found. + """ + for o in self.private_net or []: + if o.network.id == network.id: + return o + return None + + +class LoadBalancerProtection(TypedDict): + delete: bool + class LoadBalancerService(BaseDomain): """LoadBalancerService Domain @@ -123,6 +160,16 @@ class LoadBalancerService(BaseDomain): Configuration for http/https protocols, required when protocol is http/https """ + __api_properties__ = ( + "protocol", + "listen_port", + "destination_port", + "proxyprotocol", + "health_check", + "http", + ) + __slots__ = __api_properties__ + def __init__( self, protocol: str | None = None, @@ -165,6 +212,8 @@ def to_payload(self) -> dict[str, Any]: http["redirect_http"] = self.http.redirect_http if self.http.sticky_sessions is not None: http["sticky_sessions"] = self.http.sticky_sessions + if self.http.timeout_idle is not None: + http["timeout_idle"] = self.http.timeout_idle http["certificates"] = [ certificate.id for certificate in self.http.certificates or [] @@ -225,8 +274,20 @@ 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__ = ( + "cookie_name", + "cookie_lifetime", + "certificates", + "redirect_http", + "sticky_sessions", + "timeout_idle", + ) + __slots__ = __api_properties__ + def __init__( self, cookie_name: str | None = None, @@ -234,12 +295,14 @@ def __init__( certificates: list[BoundCertificate] | None = None, redirect_http: bool | None = None, sticky_sessions: bool | None = None, + timeout_idle: int | None = None, ): self.cookie_name = cookie_name self.cookie_lifetime = cookie_lifetime self.certificates = certificates self.redirect_http = redirect_http self.sticky_sessions = sticky_sessions + self.timeout_idle = timeout_idle class LoadBalancerHealthCheck(BaseDomain): @@ -255,10 +318,20 @@ class LoadBalancerHealthCheck(BaseDomain): Timeout in sec after a try is assumed as timeout :param retries: int Retries we perform until we assume a target as unhealthy - :param http: LoadBalancerHealtCheckHttp + :param http: LoadBalancerHealthCheckHttp HTTP Config """ + __api_properties__ = ( + "protocol", + "port", + "interval", + "timeout", + "retries", + "http", + ) + __slots__ = __api_properties__ + def __init__( self, protocol: str | None = None, @@ -266,7 +339,7 @@ def __init__( interval: int | None = None, timeout: int | None = None, retries: int | None = None, - http: LoadBalancerHealtCheckHttp | None = None, + http: LoadBalancerHealthCheckHttp | None = None, ): self.protocol = protocol self.port = port @@ -276,8 +349,8 @@ def __init__( self.http = http -class LoadBalancerHealtCheckHttp(BaseDomain): - """LoadBalancerHealtCheckHttp Domain +class LoadBalancerHealthCheckHttp(BaseDomain): + """LoadBalancerHealthCheckHttp Domain :param domain: str Domain name to send in HTTP request. Can be null: In that case we will not send a domain name @@ -291,12 +364,21 @@ class LoadBalancerHealtCheckHttp(BaseDomain): Type of health check """ + __api_properties__ = ( + "domain", + "path", + "response", + "status_codes", + "tls", + ) + __slots__ = __api_properties__ + def __init__( self, domain: str | None = None, path: str | None = None, response: str | None = None, - status_codes: list | None = None, + status_codes: list[str] | None = None, tls: bool | None = None, ): self.domain = domain @@ -306,6 +388,31 @@ def __init__( self.tls = tls +class LoadBalancerHealtCheckHttp(LoadBalancerHealthCheckHttp): + """ + Kept for backward compatibility. + + .. deprecated:: 2.5.4 + Use :class:``hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp`` instead. + """ + + def __init__( + self, + domain: str | None = None, + path: str | None = None, + response: str | None = None, + status_codes: list[str] | None = None, + tls: bool | None = None, + ): + warnings.warn( + "The 'hcloud.load_balancers.domain.LoadBalancerHealtCheckHttp' class is deprecated, please use the " + "'hcloud.load_balancers.domain.LoadBalancerHealthCheckHttp' class instead.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(domain, path, response, status_codes, tls) + + class LoadBalancerTarget(BaseDomain): """LoadBalancerTarget Domain @@ -321,8 +428,21 @@ 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__ = ( + "type", + "server", + "label_selector", + "ip", + "use_private_ip", + "health_status", + "targets", + ) + __slots__ = __api_properties__ + def __init__( self, type: str | None = None, @@ -331,6 +451,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 @@ -338,6 +459,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]: """ @@ -374,6 +496,12 @@ class LoadBalancerTargetHealthStatus(BaseDomain): :param status: Load Balancer Target status. Choices: healthy, unhealthy, unknown """ + __api_properties__ = ( + "listen_port", + "status", + ) + __slots__ = __api_properties__ + def __init__( self, listen_port: int | None = None, @@ -389,6 +517,9 @@ class LoadBalancerTargetLabelSelector(BaseDomain): :param selector: str Target label selector """ + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + def __init__(self, selector: str | None = None): self.selector = selector @@ -399,6 +530,9 @@ class LoadBalancerTargetIP(BaseDomain): :param ip: str Target IP """ + __api_properties__ = ("ip",) + __slots__ = __api_properties__ + def __init__(self, ip: str | None = None): self.ip = ip @@ -410,6 +544,9 @@ class LoadBalancerAlgorithm(BaseDomain): Algorithm of the Load Balancer. Choices: round_robin, least_connections """ + __api_properties__ = ("type",) + __slots__ = __api_properties__ + def __init__(self, type: str | None = None): self.type = type @@ -422,7 +559,8 @@ class PublicNetwork(BaseDomain): :param enabled: boolean """ - __slots__ = ("ipv4", "ipv6", "enabled") + __api_properties__ = ("ipv4", "ipv6", "enabled") + __slots__ = __api_properties__ def __init__( self, @@ -442,7 +580,8 @@ class IPv4Address(BaseDomain): The IPv4 Address """ - __slots__ = ("ip", "dns_ptr") + __api_properties__ = ("ip", "dns_ptr") + __slots__ = __api_properties__ def __init__( self, @@ -460,7 +599,8 @@ class IPv6Network(BaseDomain): The IPv6 Network as CIDR Notation """ - __slots__ = ("ip", "dns_ptr") + __api_properties__ = ("ip", "dns_ptr") + __slots__ = __api_properties__ def __init__( self, @@ -480,7 +620,8 @@ class PrivateNet(BaseDomain): The main IP Address of the LoadBalancer in the Network """ - __slots__ = ("network", "ip") + __api_properties__ = ("network", "ip") + __slots__ = __api_properties__ def __init__( self, @@ -500,7 +641,8 @@ class CreateLoadBalancerResponse(BaseDomain): Shows the progress of the Load Balancer creation """ - __slots__ = ("load_balancer", "action") + __api_properties__ = ("load_balancer", "action") + __slots__ = __api_properties__ def __init__( self, @@ -525,7 +667,8 @@ class GetMetricsResponse(BaseDomain): :param metrics: The Load Balancer metrics """ - __slots__ = ("metrics",) + __api_properties__ = ("metrics",) + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/locations/__init__.py b/hcloud/locations/__init__.py index 1c23a517..f59c2644 100644 --- a/hcloud/locations/__init__.py +++ b/hcloud/locations/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundLocation, LocationsClient, LocationsPageResult # noqa: F401 -from .domain import Location # noqa: F401 +from .client import BoundLocation, LocationsClient, LocationsPageResult +from .domain import Location + +__all__ = [ + "BoundLocation", + "Location", + "LocationsClient", + "LocationsPageResult", +] diff --git a/hcloud/locations/client.py b/hcloud/locations/client.py index 047ad9d4..5a71c0f6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -1,15 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Location -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundLocation", + "LocationsPageResult", + "LocationsClient", +] -class BoundLocation(BoundModelBase, Location): +class BoundLocation(BoundModelBase[Location], Location): _client: LocationsClient model = Location @@ -17,11 +20,11 @@ class BoundLocation(BoundModelBase, Location): class LocationsPageResult(NamedTuple): locations: list[BoundLocation] - meta: Meta | None + meta: Meta -class LocationsClient(ClientEntityBase): - _client: Client +class LocationsClient(ResourceClientBase): + _base_url = "/locations" def get_by_id(self, id: int) -> BoundLocation: """Get a specific location by its ID. @@ -29,7 +32,7 @@ def get_by_id(self, id: int) -> BoundLocation: :param id: int :return: :class:`BoundLocation ` """ - response = self._client.request(url=f"/locations/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundLocation(self, response["location"]) def get_list( @@ -56,7 +59,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/locations", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) locations = [ BoundLocation(self, location_data) for location_data in response["locations"] @@ -79,4 +82,4 @@ def get_by_name(self, name: str) -> BoundLocation | None: Used to get location by name. :return: :class:`BoundLocation ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/locations/domain.py b/hcloud/locations/domain.py index 7d4af223..b33b157b 100644 --- a/hcloud/locations/domain.py +++ b/hcloud/locations/domain.py @@ -2,6 +2,10 @@ from ..core import BaseDomain, DomainIdentityMixin +__all__ = [ + "Location", +] + class Location(BaseDomain, DomainIdentityMixin): """Location Domain @@ -24,7 +28,7 @@ class Location(BaseDomain, DomainIdentityMixin): Name of network zone this location resides in """ - __slots__ = ( + __api_properties__ = ( "id", "name", "description", @@ -34,6 +38,7 @@ class Location(BaseDomain, DomainIdentityMixin): "longitude", "network_zone", ) + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/metrics/__init__.py b/hcloud/metrics/__init__.py index 65d393c8..49555d10 100644 --- a/hcloud/metrics/__init__.py +++ b/hcloud/metrics/__init__.py @@ -1,3 +1,8 @@ from __future__ import annotations -from .domain import Metrics, TimeSeries # noqa: F401 +from .domain import Metrics, TimeSeries + +__all__ = [ + "Metrics", + "TimeSeries", +] diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py index 4693f155..3213e279 100644 --- a/hcloud/metrics/domain.py +++ b/hcloud/metrics/domain.py @@ -1,13 +1,17 @@ from __future__ import annotations from datetime import datetime -from typing import Dict, List, Literal, Tuple - -from dateutil.parser import isoparse +from typing import Literal from ..core import BaseDomain -TimeSeries = Dict[str, Dict[Literal["values"], List[Tuple[float, str]]]] +__all__ = [ + "TimeSeries", + "Metrics", +] + + +TimeSeries = dict[str, dict[Literal["values"], list[tuple[float, str]]]] class Metrics(BaseDomain): @@ -26,12 +30,13 @@ class Metrics(BaseDomain): step: float time_series: TimeSeries - __slots__ = ( + __api_properties__ = ( "start", "end", "step", "time_series", ) + __slots__ = __api_properties__ def __init__( self, @@ -40,7 +45,7 @@ def __init__( step: float, time_series: TimeSeries, ): - self.start = isoparse(start) - self.end = isoparse(end) + self.start = self._parse_datetime(start) + self.end = self._parse_datetime(end) self.step = step self.time_series = time_series diff --git a/hcloud/networks/__init__.py b/hcloud/networks/__init__.py index 5bf4a88f..f49b9934 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -1,9 +1,21 @@ from __future__ import annotations -from .client import BoundNetwork, NetworksClient, NetworksPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundNetwork, NetworksClient, NetworksPageResult +from .domain import ( CreateNetworkResponse, Network, + NetworkProtection, NetworkRoute, NetworkSubnet, ) + +__all__ = [ + "BoundNetwork", + "CreateNetworkResponse", + "Network", + "NetworkProtection", + "NetworkRoute", + "NetworkSubnet", + "NetworksClient", + "NetworksPageResult", +] diff --git a/hcloud/networks/client.py b/hcloud/networks/client.py index d819d580..73b33e5d 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -2,20 +2,39 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import Network, NetworkRoute, NetworkSubnet if TYPE_CHECKING: from .._client import Client -class BoundNetwork(BoundModelBase, Network): +__all__ = [ + "BoundNetwork", + "NetworksPageResult", + "NetworksClient", +] + + +class BoundNetwork(BoundModelBase[Network], Network): _client: NetworksClient model = Network - def __init__(self, client: NetworksClient, data: dict, complete: bool = True): + def __init__( + self, + client: NetworksClient, + data: dict[str, Any], + complete: bool = True, + ): subnets = data.get("subnets", []) if subnets is not None: subnets = [NetworkSubnet.from_dict(subnet) for subnet in subnets] @@ -32,7 +51,7 @@ def __init__(self, client: NetworksClient, data: dict, complete: bool = True): servers = data.get("servers", []) if servers is not None: servers = [ - BoundServer(client._client.servers, {"id": server}, complete=False) + BoundServer(client._parent.servers, {"id": server}, complete=False) for server in servers ] data["servers"] = servers @@ -72,39 +91,39 @@ def delete(self) -> bool: def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a network. + """ + Returns a paginated list of Actions for a Network. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a network. + """ + Returns all Actions for a Network. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def add_subnet(self, subnet: NetworkSubnet) -> BoundAction: """Adds a subnet entry to a network. @@ -163,11 +182,14 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: class NetworksPageResult(NamedTuple): networks: list[BoundNetwork] - meta: Meta | None + meta: Meta -class NetworksClient(ClientEntityBase): - _client: Client +class NetworksClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/networks" actions: ResourceActionsClient """Networks scoped actions client @@ -177,7 +199,7 @@ class NetworksClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/networks") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundNetwork: """Get a specific network @@ -185,7 +207,7 @@ def get_by_id(self, id: int) -> BoundNetwork: :param id: int :return: :class:`BoundNetwork ` """ - response = self._client.request(url=f"/networks/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundNetwork(self, response["network"]) def get_list( @@ -217,7 +239,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/networks", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) networks = [ BoundNetwork(self, network_data) for network_data in response["networks"] @@ -246,7 +268,7 @@ def get_by_name(self, name: str) -> BoundNetwork | None: Used to get network by name. :return: :class:`BoundNetwork ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -301,7 +323,7 @@ def create( if labels is not None: data["labels"] = labels - response = self._client.request(url="/networks", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundNetwork(self, response["network"]) @@ -335,7 +357,7 @@ def update( data.update({"labels": labels}) response = self._client.request( - url=f"/networks/{network.id}", + url=f"{self._base_url}/{network.id}", method="PUT", json=data, ) @@ -347,65 +369,46 @@ def delete(self, network: Network | BoundNetwork) -> bool: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: boolean """ - self._client.request(url=f"/networks/{network.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") return True def get_actions_list( self, network: Network | BoundNetwork, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a network. - - :param network: :class:`BoundNetwork ` or :class:`Network ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Network. - response = self._client.request( - url=f"/networks/{network.id}/actions", - method="GET", - params=params, + :param network: Network to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{network.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, network: Network | BoundNetwork, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a network. + """ + Returns all Actions for a Network. - :param network: :class:`BoundNetwork ` or :class:`Network ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param network: Network to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -436,11 +439,11 @@ def add_subnet( data["vswitch_id"] = subnet.vswitch_id response = self._client.request( - url=f"/networks/{network.id}/actions/add_subnet", + url=f"{self._base_url}/{network.id}/actions/add_subnet", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_subnet( self, @@ -457,11 +460,11 @@ def delete_subnet( data: dict[str, Any] = {"ip_range": subnet.ip_range} response = self._client.request( - url=f"/networks/{network.id}/actions/delete_subnet", + url=f"{self._base_url}/{network.id}/actions/delete_subnet", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_route( self, @@ -481,11 +484,11 @@ def add_route( } response = self._client.request( - url=f"/networks/{network.id}/actions/add_route", + url=f"{self._base_url}/{network.id}/actions/add_route", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def delete_route( self, @@ -505,11 +508,11 @@ def delete_route( } response = self._client.request( - url=f"/networks/{network.id}/actions/delete_route", + url=f"{self._base_url}/{network.id}/actions/delete_route", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_ip_range( self, @@ -526,11 +529,11 @@ def change_ip_range( data: dict[str, Any] = {"ip_range": ip_range} response = self._client.request( - url=f"/networks/{network.id}/actions/change_ip_range", + url=f"{self._base_url}/{network.id}/actions/change_ip_range", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -549,8 +552,8 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/networks/{network.id}/actions/change_protection", + url=f"{self._base_url}/{network.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py index 2135d5fc..80c3badc 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -1,8 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +import warnings +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -11,6 +10,14 @@ from ..servers import BoundServer from .client import BoundNetwork +__all__ = [ + "Network", + "NetworkProtection", + "NetworkSubnet", + "NetworkRoute", + "CreateNetworkResponse", +] + class Network(BaseDomain, DomainIdentityMixin): """Network Domain @@ -35,7 +42,7 @@ class Network(BaseDomain, DomainIdentityMixin): User-defined labels (key-value pairs) """ - __slots__ = ( + __api_properties__ = ( "id", "name", "ip_range", @@ -47,6 +54,7 @@ class Network(BaseDomain, DomainIdentityMixin): "labels", "created", ) + __slots__ = __api_properties__ def __init__( self, @@ -58,12 +66,12 @@ def __init__( routes: list[NetworkRoute] | None = None, expose_routes_to_vswitch: bool | None = None, servers: list[BoundServer] | None = None, - protection: dict | None = None, + protection: NetworkProtection | None = None, labels: dict[str, str] | None = None, ): self.id = id self.name = name - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.ip_range = ip_range self.subnets = subnets self.routes = routes @@ -73,6 +81,10 @@ def __init__( self.labels = labels +class NetworkProtection(TypedDict): + delete: bool + + class NetworkSubnet(BaseDomain): """Network Subnet Domain @@ -88,13 +100,34 @@ class NetworkSubnet(BaseDomain): ID of the vSwitch. """ - TYPE_SERVER = "server" - """Subnet Type server, deprecated, use TYPE_CLOUD instead""" + @property + def TYPE_SERVER(self) -> str: # pylint: disable=invalid-name + """ + Used to connect cloud servers and load balancers. + + .. deprecated:: 2.2.0 + Use :attr:`NetworkSubnet.TYPE_CLOUD` instead. + """ + warnings.warn( + "The 'NetworkSubnet.TYPE_SERVER' property is deprecated, please use the `NetworkSubnet.TYPE_CLOUD` property instead.", + DeprecationWarning, + stacklevel=2, + ) + return "server" + TYPE_CLOUD = "cloud" - """Subnet Type cloud""" + """ + Used to connect cloud servers and load balancers. + """ TYPE_VSWITCH = "vswitch" - """Subnet Type vSwitch""" - __slots__ = ("type", "ip_range", "network_zone", "gateway", "vswitch_id") + """ + Used to connect cloud servers and load balancers with dedicated servers. + + See https://docs.hetzner.com/networking/networks/connect-dedi-vswitch/ + """ + + __api_properties__ = ("type", "ip_range", "network_zone", "gateway", "vswitch_id") + __slots__ = __api_properties__ def __init__( self, @@ -120,7 +153,8 @@ class NetworkRoute(BaseDomain): Gateway for the route. """ - __slots__ = ("destination", "gateway") + __api_properties__ = ("destination", "gateway") + __slots__ = __api_properties__ def __init__(self, destination: str, gateway: str): self.destination = destination @@ -136,7 +170,8 @@ class CreateNetworkResponse(BaseDomain): The Action which shows the progress of the network Creation """ - __slots__ = ("network", "action") + __api_properties__ = ("network", "action") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/placement_groups/__init__.py b/hcloud/placement_groups/__init__.py index 9c25dd7f..ab82da5a 100644 --- a/hcloud/placement_groups/__init__.py +++ b/hcloud/placement_groups/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundPlacementGroup, PlacementGroupsClient, PlacementGroupsPageResult, ) -from .domain import CreatePlacementGroupResponse, PlacementGroup # noqa: F401 +from .domain import CreatePlacementGroupResponse, PlacementGroup + +__all__ = [ + "BoundPlacementGroup", + "CreatePlacementGroupResponse", + "PlacementGroup", + "PlacementGroupsClient", + "PlacementGroupsPageResult", +] diff --git a/hcloud/placement_groups/client.py b/hcloud/placement_groups/client.py index fcfd86ae..041118d8 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -1,16 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple from ..actions import BoundAction -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePlacementGroupResponse, PlacementGroup -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundPlacementGroup", + "PlacementGroupsPageResult", + "PlacementGroupsClient", +] -class BoundPlacementGroup(BoundModelBase, PlacementGroup): +class BoundPlacementGroup(BoundModelBase[PlacementGroup], PlacementGroup): _client: PlacementGroupsClient model = PlacementGroup @@ -28,7 +31,7 @@ def update( New Name to set :return: :class:`BoundPlacementGroup ` """ - return self._client.update(self, labels, name) + return self._client.update(self, labels=labels, name=name) def delete(self) -> bool: """Deletes a Placement Group @@ -40,11 +43,11 @@ def delete(self) -> bool: class PlacementGroupsPageResult(NamedTuple): placement_groups: list[BoundPlacementGroup] - meta: Meta | None + meta: Meta -class PlacementGroupsClient(ClientEntityBase): - _client: Client +class PlacementGroupsClient(ResourceClientBase): + _base_url = "/placement_groups" def get_by_id(self, id: int) -> BoundPlacementGroup: """Returns a specific Placement Group object @@ -53,7 +56,7 @@ def get_by_id(self, id: int) -> BoundPlacementGroup: :return: :class:`BoundPlacementGroup ` """ response = self._client.request( - url=f"/placement_groups/{id}", + url=f"{self._base_url}/{id}", method="GET", ) return BoundPlacementGroup(self, response["placement_group"]) @@ -96,9 +99,7 @@ def get_list( params["sort"] = sort if type is not None: params["type"] = type - response = self._client.request( - url="/placement_groups", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) placement_groups = [ BoundPlacementGroup(self, placement_group_data) for placement_group_data in response["placement_groups"] @@ -136,7 +137,7 @@ def get_by_name(self, name: str) -> BoundPlacementGroup | None: Used to get Placement Group by name :return: class:`BoundPlacementGroup ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -158,13 +159,11 @@ def create( data: dict[str, Any] = {"name": name, "type": type} if labels is not None: data["labels"] = labels - response = self._client.request( - url="/placement_groups", json=data, method="POST" - ) + response = self._client.request(url=self._base_url, json=data, method="POST") action = None if response.get("action") is not None: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePlacementGroupResponse( placement_group=BoundPlacementGroup(self, response["placement_group"]), @@ -195,7 +194,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/placement_groups/{placement_group.id}", + url=f"{self._base_url}/{placement_group.id}", method="PUT", json=data, ) @@ -208,7 +207,7 @@ def delete(self, placement_group: PlacementGroup | BoundPlacementGroup) -> bool: :return: boolean """ self._client.request( - url=f"/placement_groups/{placement_group.id}", + url=f"{self._base_url}/{placement_group.id}", method="DELETE", ) return True diff --git a/hcloud/placement_groups/domain.py b/hcloud/placement_groups/domain.py index 7b7fcb34..1dc9b9dd 100644 --- a/hcloud/placement_groups/domain.py +++ b/hcloud/placement_groups/domain.py @@ -2,14 +2,17 @@ from typing import TYPE_CHECKING -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin if TYPE_CHECKING: from ..actions import BoundAction from .client import BoundPlacementGroup +__all__ = [ + "PlacementGroup", + "CreatePlacementGroupResponse", +] + class PlacementGroup(BaseDomain, DomainIdentityMixin): """Placement Group Domain @@ -28,7 +31,8 @@ class PlacementGroup(BaseDomain, DomainIdentityMixin): Point in time when the image was created """ - __slots__ = ("id", "name", "labels", "servers", "type", "created") + __api_properties__ = ("id", "name", "labels", "servers", "type", "created") + __slots__ = __api_properties__ """Placement Group type spread spreads all servers in the group on different vhosts @@ -49,7 +53,7 @@ def __init__( self.labels = labels self.servers = servers self.type = type - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) class CreatePlacementGroupResponse(BaseDomain): @@ -61,7 +65,8 @@ class CreatePlacementGroupResponse(BaseDomain): The Action which shows the progress of the Placement Group Creation """ - __slots__ = ("placement_group", "action") + __api_properties__ = ("placement_group", "action") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/primary_ips/__init__.py b/hcloud/primary_ips/__init__.py index d079a23b..99974046 100644 --- a/hcloud/primary_ips/__init__.py +++ b/hcloud/primary_ips/__init__.py @@ -1,4 +1,13 @@ from __future__ import annotations -from .client import BoundPrimaryIP, PrimaryIPsClient, PrimaryIPsPageResult # noqa: F401 -from .domain import CreatePrimaryIPResponse, PrimaryIP # noqa: F401 +from .client import BoundPrimaryIP, PrimaryIPsClient, PrimaryIPsPageResult +from .domain import CreatePrimaryIPResponse, PrimaryIP, PrimaryIPProtection + +__all__ = [ + "BoundPrimaryIP", + "CreatePrimaryIPResponse", + "PrimaryIP", + "PrimaryIPProtection", + "PrimaryIPsClient", + "PrimaryIPsPageResult", +] diff --git a/hcloud/primary_ips/client.py b/hcloud/primary_ips/client.py index ece8d88f..08efdf34 100644 --- a/hcloud/primary_ips/client.py +++ b/hcloud/primary_ips/client.py @@ -1,31 +1,91 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import CreatePrimaryIPResponse, PrimaryIP if TYPE_CHECKING: from .._client import Client - from ..datacenters import BoundDatacenter, Datacenter + from ..locations import BoundLocation, Location -class BoundPrimaryIP(BoundModelBase, PrimaryIP): +__all__ = [ + "BoundPrimaryIP", + "PrimaryIPsPageResult", + "PrimaryIPsClient", +] + + +class BoundPrimaryIP(BoundModelBase[PrimaryIP], PrimaryIP): _client: PrimaryIPsClient model = PrimaryIP - def __init__(self, client: PrimaryIPsClient, data: dict, complete: bool = True): + def __init__( + self, + client: PrimaryIPsClient, + data: dict[str, Any], + complete: bool = True, + ): # pylint: disable=import-outside-toplevel - from ..datacenters import BoundDatacenter + from ..locations import BoundLocation - datacenter = data.get("datacenter", {}) - if datacenter: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + raw = data.get("location", {}) + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) super().__init__(client, data, complete) + def get_actions_list( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Primary IP. + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Primary IP. + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + def update( self, auto_delete: bool | None = None, @@ -43,7 +103,10 @@ def update( :return: :class:`BoundPrimaryIP ` """ return self._client.update( - self, auto_delete=auto_delete, labels=labels, name=name + self, + auto_delete=auto_delete, + labels=labels, + name=name, ) def delete(self) -> bool: @@ -60,7 +123,7 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If true, prevents the Primary IP from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) def assign(self, assignee_id: int, assignee_type: str) -> BoundAction: """Assigns a Primary IP to a assignee. @@ -71,7 +134,9 @@ def assign(self, assignee_id: int, assignee_type: str) -> BoundAction: Assignee type (e.g server) the Primary IP shall be assigned to :return: :class:`BoundAction ` """ - return self._client.assign(self, assignee_id, assignee_type) + return self._client.assign( + self, assignee_id=assignee_id, assignee_type=assignee_type + ) def unassign(self) -> BoundAction: """Unassigns a Primary IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -89,16 +154,19 @@ def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) class PrimaryIPsPageResult(NamedTuple): primary_ips: list[BoundPrimaryIP] - meta: Meta | None + meta: Meta -class PrimaryIPsClient(ClientEntityBase): - _client: Client +class PrimaryIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/primary_ips" actions: ResourceActionsClient """Primary IPs scoped actions client @@ -108,7 +176,52 @@ class PrimaryIPsClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/primary_ips") + self.actions = ResourceActionsClient(client, self._base_url) + + def get_actions_list( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Primary IP. + + :param primary_ip: Primary IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{primary_ip.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Primary IP. + + :param primary_ip: Primary IP to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + primary_ip, + status=status, + sort=sort, + ) def get_by_id(self, id: int) -> BoundPrimaryIP: """Returns a specific Primary IP object. @@ -116,7 +229,7 @@ def get_by_id(self, id: int) -> BoundPrimaryIP: :param id: int :return: :class:`BoundPrimaryIP ` """ - response = self._client.request(url=f"/primary_ips/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundPrimaryIP(self, response["primary_ip"]) def get_list( @@ -154,7 +267,7 @@ def get_list( if ip is not None: params["ip"] = ip - response = self._client.request(url="/primary_ips", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) primary_ips = [ BoundPrimaryIP(self, primary_ip_data) for primary_ip_data in response["primary_ips"] @@ -184,51 +297,61 @@ def get_by_name(self, name: str) -> BoundPrimaryIP | None: Used to get Primary IP by name. :return: :class:`BoundPrimaryIP ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, type: str, - # TODO: Make the datacenter argument optional - datacenter: Datacenter | BoundDatacenter | None, name: str, - assignee_type: str | None = "server", + location: Location | BoundLocation | None = None, + assignee_type: str | None = None, assignee_id: int | None = None, auto_delete: bool | None = False, - labels: dict | None = None, + labels: dict[str, str] | None = None, ) -> CreatePrimaryIPResponse: """Creates a new Primary IP assigned to a server. - :param type: str - Primary IP type Choices: ipv4, ipv6 - :param assignee_type: str - :param assignee_id: int (optional) - :param datacenter: Datacenter - :param labels: Dict[str, str] (optional) - User-defined labels (key-value pairs) + :param type: str Primary IP type Choices: ipv4, ipv6 :param name: str + :param location: Location (optional) + :param assignee_type: str (optional) + :param assignee_id: int (optional) :param auto_delete: bool (optional) + :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, - "name": name, } - if datacenter is not None: - 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="/primary_ips", 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: - action = BoundAction(self._client.actions, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePrimaryIPResponse( primary_ip=BoundPrimaryIP(self, response["primary_ip"]), action=action @@ -262,7 +385,7 @@ def update( data["name"] = name response = self._client.request( - url=f"/primary_ips/{primary_ip.id}", + url=f"{self._base_url}/{primary_ip.id}", method="PUT", json=data, ) @@ -275,7 +398,7 @@ def delete(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> bool: :return: boolean """ self._client.request( - url=f"/primary_ips/{primary_ip.id}", + url=f"{self._base_url}/{primary_ip.id}", method="DELETE", ) # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised @@ -298,11 +421,11 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/change_protection", + url=f"{self._base_url}/{primary_ip.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def assign( self, @@ -320,11 +443,11 @@ def assign( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/assign", + url=f"{self._base_url}/{primary_ip.id}/actions/assign", method="POST", json={"assignee_id": assignee_id, "assignee_type": assignee_type}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: """Unassigns a Primary IP, resulting in it being unreachable. You may assign it to a server again at a later time. @@ -333,10 +456,10 @@ def unassign(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/unassign", + url=f"{self._base_url}/{primary_ip.id}/actions/unassign", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -354,8 +477,8 @@ def change_dns_ptr( :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/primary_ips/{primary_ip.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{primary_ip.id}/actions/change_dns_ptr", method="POST", json={"ip": ip, "dns_ptr": dns_ptr}, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py index bd470602..2e964498 100644 --- a/hcloud/primary_ips/domain.py +++ b/hcloud/primary_ips/domain.py @@ -1,16 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +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 +__all__ = [ + "PrimaryIP", + "PrimaryIPProtection", + "CreatePrimaryIPResponse", +] + class PrimaryIP(BaseDomain, DomainIdentityMixin): """Primary IP Domain @@ -23,8 +28,8 @@ 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. + :param location: :class:`Location ` + Location the Primary IP was created in. :param blocked: boolean Whether the IP is blocked :param protection: dict @@ -43,12 +48,12 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): Delete the Primary IP when the Assignee it is assigned to is deleted. """ - __slots__ = ( + __api_properties__ = ( "id", "ip", "type", "dns_ptr", - "datacenter", + "location", "blocked", "protection", "labels", @@ -58,17 +63,18 @@ class PrimaryIP(BaseDomain, DomainIdentityMixin): "assignee_type", "auto_delete", ) + __slots__ = __api_properties__ def __init__( self, id: int | None = None, type: str | None = None, ip: str | None = None, - dns_ptr: list[dict] | None = None, - datacenter: BoundDatacenter | None = None, + dns_ptr: list[DNSPtr] | None = None, + location: BoundLocation | None = None, blocked: bool | None = None, - protection: dict | None = None, - labels: dict[str, dict] | None = None, + protection: PrimaryIPProtection | None = None, + labels: dict[str, str] | None = None, created: str | None = None, name: str | None = None, assignee_id: int | None = None, @@ -79,17 +85,21 @@ 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 self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.name = name self.assignee_id = assignee_id self.assignee_type = assignee_type self.auto_delete = auto_delete +class PrimaryIPProtection(TypedDict): + delete: bool + + class CreatePrimaryIPResponse(BaseDomain): """Create Primary IP Response Domain @@ -99,7 +109,8 @@ class CreatePrimaryIPResponse(BaseDomain): The Action which shows the progress of the Primary IP Creation """ - __slots__ = ("primary_ip", "action") + __api_properties__ = ("primary_ip", "action") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/rdns/__init__.py b/hcloud/rdns/__init__.py new file mode 100644 index 00000000..116a632b --- /dev/null +++ b/hcloud/rdns/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .domain import DNSPtr + +__all__ = [ + "DNSPtr", +] diff --git a/hcloud/rdns/domain.py b/hcloud/rdns/domain.py new file mode 100644 index 00000000..e0e5ac66 --- /dev/null +++ b/hcloud/rdns/domain.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import TypedDict + +__all__ = [ + "DNSPtr", +] + + +class DNSPtr(TypedDict): + ip: str + dns_ptr: str diff --git a/hcloud/server_types/__init__.py b/hcloud/server_types/__init__.py index 1e978d09..6840e327 100644 --- a/hcloud/server_types/__init__.py +++ b/hcloud/server_types/__init__.py @@ -1,8 +1,16 @@ from __future__ import annotations -from .client import ( # noqa: F401 +from .client import ( BoundServerType, ServerTypesClient, ServerTypesPageResult, ) -from .domain import ServerType # noqa: F401 +from .domain import ServerType, ServerTypeLocation + +__all__ = [ + "BoundServerType", + "ServerType", + "ServerTypeLocation", + "ServerTypesClient", + "ServerTypesPageResult", +] diff --git a/hcloud/server_types/client.py b/hcloud/server_types/client.py index 31f56a20..98da70af 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -1,27 +1,53 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta -from .domain import ServerType +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation +from .domain import ServerType, ServerTypeLocation -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundServerType", + "ServerTypesPageResult", + "ServerTypesClient", +] -class BoundServerType(BoundModelBase, ServerType): +class BoundServerType(BoundModelBase[ServerType], ServerType): _client: ServerTypesClient model = ServerType + def __init__( + self, + client: ServerTypesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("locations") + if raw is not None: + data["locations"] = [ + ServerTypeLocation.from_dict( + { + "location": BoundLocation( + client._parent.locations, o, complete=False + ), + **o, + } + ) + for o in raw + ] + + super().__init__(client, data, complete) + class ServerTypesPageResult(NamedTuple): server_types: list[BoundServerType] - meta: Meta | None + meta: Meta -class ServerTypesClient(ClientEntityBase): - _client: Client +class ServerTypesClient(ResourceClientBase): + _base_url = "/server_types" def get_by_id(self, id: int) -> BoundServerType: """Returns a specific Server Type. @@ -29,7 +55,7 @@ def get_by_id(self, id: int) -> BoundServerType: :param id: int :return: :class:`BoundServerType ` """ - response = self._client.request(url=f"/server_types/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServerType(self, response["server_type"]) def get_list( @@ -56,9 +82,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request( - url="/server_types", method="GET", params=params - ) + response = self._client.request(url=self._base_url, method="GET", params=params) server_types = [ BoundServerType(self, server_type_data) for server_type_data in response["server_types"] @@ -81,4 +105,4 @@ def get_by_name(self, name: str) -> BoundServerType | None: Used to get Server type by name. :return: :class:`BoundServerType ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) diff --git a/hcloud/server_types/domain.py b/hcloud/server_types/domain.py index ab2553b1..cafa873a 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -1,7 +1,16 @@ from __future__ import annotations +import warnings +from typing import Any + from ..core import BaseDomain, DomainIdentityMixin from ..deprecation import DeprecationInfo +from ..locations import BoundLocation + +__all__ = [ + "ServerType", + "ServerTypeLocation", +] class ServerType(BaseDomain, DomainIdentityMixin): @@ -13,13 +22,15 @@ class ServerType(BaseDomain, DomainIdentityMixin): Unique identifier of the server type :param description: str Description of the server type + :param category: str + Category of the Server Type. :param cores: int Number of cpu cores a server of this type will have :param memory: int Memory a server of this type will have in GB :param disk: int Disk size a server of this type will have in GB - :param prices: Dict + :param prices: List of dict Prices in different locations :param storage_type: str Type of server boot drive. Local has higher speed. Network has better availability. Choices: `local`, `network` @@ -34,12 +45,14 @@ class ServerType(BaseDomain, DomainIdentityMixin): deprecated. If it has a value, it is considered deprecated. :param included_traffic: int Free traffic per month in bytes + :param locations: Supported Location of the Server Type. """ - __slots__ = ( + __properties__ = ( "id", "name", "description", + "category", "cores", "memory", "disk", @@ -47,30 +60,44 @@ class ServerType(BaseDomain, DomainIdentityMixin): "storage_type", "cpu_type", "architecture", + "locations", + ) + __api_properties__ = ( + *__properties__, "deprecated", "deprecation", "included_traffic", ) + __slots__ = ( + *__properties__, + "_deprecated", + "_deprecation", + "_included_traffic", + ) + # pylint: disable=too-many-locals def __init__( self, id: int | None = None, name: str | None = None, description: str | None = None, + category: str | None = None, cores: int | None = None, memory: int | None = None, disk: int | None = None, - prices: dict | None = None, + prices: list[dict[str, Any]] | None = None, storage_type: str | None = None, cpu_type: str | None = None, architecture: str | None = None, deprecated: bool | None = None, - deprecation: dict | None = None, + deprecation: dict[str, Any] | None = None, included_traffic: int | None = None, + locations: list[ServerTypeLocation] | None = None, ): self.id = id self.name = name self.description = description + self.category = category self.cores = cores self.memory = memory self.disk = disk @@ -78,8 +105,109 @@ def __init__( self.storage_type = storage_type self.cpu_type = cpu_type self.architecture = architecture + self.locations = locations + self.deprecated = deprecated self.deprecation = ( DeprecationInfo.from_dict(deprecation) if deprecation is not None else None ) self.included_traffic = included_traffic + + @property + def deprecated(self) -> bool | None: + """ + .. deprecated:: 2.6.0 + The 'deprecated' property is deprecated and will gradually be phased starting 24 September 2025. + Please refer to the '.locations[].deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types. + """ + warnings.warn( + "The 'deprecated' property is deprecated and will gradually be phased starting 24 September 2025. " + "Please refer to the '.locations[].deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types", + DeprecationWarning, + stacklevel=2, + ) + return self._deprecated + + @deprecated.setter + def deprecated(self, value: bool | None) -> None: + self._deprecated = value + + @property + def deprecation(self) -> DeprecationInfo | None: + """ + .. deprecated:: 2.6.0 + The 'deprecation' property is deprecated and will gradually be phased starting 24 September 2025. + Please refer to the '.locations[].deprecation' property instead. + + See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types. + """ + warnings.warn( + "The 'deprecation' property is deprecated and will gradually be phased starting 24 September 2025. " + "Please refer to the '.locations[].deprecation' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types", + DeprecationWarning, + stacklevel=2, + ) + return self._deprecation + + @deprecation.setter + def deprecation(self, value: DeprecationInfo | None) -> None: + self._deprecation = value + + @property + def included_traffic(self) -> int | None: + """ + .. deprecated:: 2.1.0 + The 'included_traffic' property is deprecated and will be set to 'None' on 5 August 2024. + Please refer to the 'prices' property instead. + + See https://docs.hetzner.cloud/changelog#2024-07-25-cloud-api-returns-traffic-information-in-different-format. + """ + warnings.warn( + "The 'included_traffic' property is deprecated and will be set to 'None' on 5 August 2024. " + "Please refer to the 'prices' property instead. " + "See https://docs.hetzner.cloud/changelog#2024-07-25-cloud-api-returns-traffic-information-in-different-format", + DeprecationWarning, + stacklevel=2, + ) + return self._included_traffic + + @included_traffic.setter + def included_traffic(self, value: int | None) -> None: + self._included_traffic = value + + +class ServerTypeLocation(BaseDomain): + """Server Type Location Domain + + :param location: Location of the Server Type. + :param deprecation: Wether the Server Type is deprecated in this Location. + :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__ + + def __init__( + self, + *, + 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/__init__.py b/hcloud/servers/__init__.py index 58c811e5..c71c63ef 100644 --- a/hcloud/servers/__init__.py +++ b/hcloud/servers/__init__.py @@ -1,17 +1,41 @@ from __future__ import annotations -from .client import BoundServer, ServersClient, ServersPageResult # noqa: F401 -from .domain import ( # noqa: F401 +from .client import BoundServer, ServersClient, ServersPageResult +from .domain import ( CreateServerResponse, EnableRescueResponse, GetMetricsResponse, IPv4Address, IPv6Network, + MetricsType, PrivateNet, PublicNetwork, PublicNetworkFirewall, + RebuildResponse, RequestConsoleResponse, ResetPasswordResponse, Server, ServerCreatePublicNetwork, + ServerProtection, ) + +__all__ = [ + "BoundServer", + "CreateServerResponse", + "EnableRescueResponse", + "GetMetricsResponse", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "PublicNetwork", + "PublicNetworkFirewall", + "RequestConsoleResponse", + "ResetPasswordResponse", + "Server", + "ServerProtection", + "ServerCreatePublicNetwork", + "ServersClient", + "ServersPageResult", + "RebuildResponse", + "MetricsType", +] diff --git a/hcloud/servers/client.py b/hcloud/servers/client.py index e4b26795..68cbb9ce 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,18 +1,24 @@ from __future__ import annotations -import warnings from datetime import datetime from typing import TYPE_CHECKING, Any, NamedTuple from dateutil.parser import isoparse -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta -from ..datacenters import BoundDatacenter +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from ..firewalls import BoundFirewall from ..floating_ips import BoundFloatingIP from ..images import BoundImage, CreateImageResponse from ..isos import BoundIso +from ..locations import BoundLocation, Location from ..metrics import Metrics from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP @@ -36,11 +42,9 @@ if TYPE_CHECKING: from .._client import Client - from ..datacenters import Datacenter from ..firewalls import Firewall from ..images import Image from ..isos import Iso - from ..locations import BoundLocation, Location from ..networks import BoundNetwork, Network from ..placement_groups import PlacementGroup from ..server_types import ServerType @@ -49,37 +53,49 @@ from .domain import ServerCreatePublicNetwork -class BoundServer(BoundModelBase, Server): +__all__ = [ + "BoundServer", + "ServersPageResult", + "ServersClient", +] + + +class BoundServer(BoundModelBase[Server], Server): _client: ServersClient model = Server # pylint: disable=too-many-locals - def __init__(self, client: ServersClient, data: dict, complete: bool = True): - datacenter = data.get("datacenter") - if datacenter is not None: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + def __init__( + self, + client: ServersClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("location") + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) volumes = data.get("volumes", []) if volumes: volumes = [ - BoundVolume(client._client.volumes, {"id": volume}, complete=False) + BoundVolume(client._parent.volumes, {"id": volume}, complete=False) for volume in volumes ] data["volumes"] = volumes image = data.get("image", None) if image is not None: - data["image"] = BoundImage(client._client.images, image) + data["image"] = BoundImage(client._parent.images, image) iso = data.get("iso", None) if iso is not None: - data["iso"] = BoundIso(client._client.isos, iso) + data["iso"] = BoundIso(client._parent.isos, iso) server_type = data.get("server_type") if server_type is not None: data["server_type"] = BoundServerType( - client._client.server_types, server_type + client._parent.server_types, server_type ) public_net = data.get("public_net") @@ -91,7 +107,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv4_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv4"]["id"]}, complete=False, ) @@ -105,7 +121,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) ipv6_primary_ip = ( BoundPrimaryIP( - client._client.primary_ips, + client._parent.primary_ips, {"id": public_net["ipv6"]["id"]}, complete=False, ) @@ -114,14 +130,14 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): ) floating_ips = [ BoundFloatingIP( - client._client.floating_ips, {"id": floating_ip}, complete=False + client._parent.floating_ips, {"id": floating_ip}, complete=False ) for floating_ip in public_net["floating_ips"] ] firewalls = [ PublicNetworkFirewall( BoundFirewall( - client._client.firewalls, {"id": firewall["id"]}, complete=False + client._parent.firewalls, {"id": firewall["id"]}, complete=False ), status=firewall["status"], ) @@ -144,7 +160,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -159,7 +175,7 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): placement_group = data.get("placement_group") if placement_group: placement_group = BoundPlacementGroup( - client._client.placement_groups, placement_group + client._parent.placement_groups, placement_group ) data["placement_group"] = placement_group @@ -167,39 +183,39 @@ def __init__(self, client: ServersClient, data: dict, complete: bool = True): def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a server. + """ + Returns a paginated list of Actions for a Server. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a server. + """ + Returns all Actions for a Server. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -214,7 +230,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundServer ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def get_metrics( self, @@ -328,20 +344,23 @@ def create_image( User-defined labels (key-value pairs) :return: :class:`CreateImageResponse ` """ - return self._client.create_image(self, description, type, labels) + return self._client.create_image( + self, description=description, type=type, labels=labels + ) def rebuild( self, image: Image | BoundImage, - *, - return_response: bool = False, - ) -> RebuildResponse | BoundAction: + user_data: str | None = None, + # pylint: disable=unused-argument + **kwargs: Any, + ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. :param image: Image to use for the rebuilt server - :param return_response: Whether to return the full response or only the action. + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ - return self._client.rebuild(self, image, return_response=return_response) + return self._client.rebuild(self, image=image, user_data=user_data) def change_type( self, @@ -356,7 +375,9 @@ def change_type( If false, do not upgrade the disk. This allows downgrading the server type later. :return: :class:`BoundAction ` """ - return self._client.change_type(self, server_type, upgrade_disk) + return self._client.change_type( + self, server_type=server_type, upgrade_disk=upgrade_disk + ) def enable_backup(self) -> BoundAction: """Enables and configures the automatic daily backup option for the server. Enabling automatic backups will increase the price of the server by 20%. @@ -378,7 +399,7 @@ def attach_iso(self, iso: Iso | BoundIso) -> BoundAction: :param iso: :class:`BoundIso ` or :class:`Server ` :return: :class:`BoundAction ` """ - return self._client.attach_iso(self, iso) + return self._client.attach_iso(self, iso=iso) def detach_iso(self) -> BoundAction: """Detaches an ISO from a server. @@ -396,7 +417,7 @@ def change_dns_ptr(self, ip: str, dns_ptr: str | None) -> BoundAction: Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - return self._client.change_dns_ptr(self, ip, dns_ptr) + return self._client.change_dns_ptr(self, ip=ip, dns_ptr=dns_ptr) def change_protection( self, @@ -412,7 +433,7 @@ def change_protection( If true, prevents the server from being rebuilt (currently delete and rebuild attribute needs to have the same value) :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete, rebuild) + return self._client.change_protection(self, delete=delete, rebuild=rebuild) def request_console(self) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. @@ -426,6 +447,7 @@ def attach_to_network( network: Network | BoundNetwork, ip: str | None = None, alias_ips: list[str] | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a server to a network @@ -434,9 +456,17 @@ def attach_to_network( IP to request to be assigned to this server :param alias_ips: List[str] New alias IPs to set for this server. + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ - return self._client.attach_to_network(self, network, ip, alias_ips) + return self._client.attach_to_network( + self, + network=network, + ip=ip, + alias_ips=alias_ips, + ip_range=ip_range, + ) def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a server from a network. @@ -444,7 +474,7 @@ def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.detach_from_network(self, network) + return self._client.detach_from_network(self, network=network) def change_alias_ips( self, @@ -458,7 +488,7 @@ def change_alias_ips( New alias IPs to set for this server. :return: :class:`BoundAction ` """ - return self._client.change_alias_ips(self, network, alias_ips) + return self._client.change_alias_ips(self, network=network, alias_ips=alias_ips) def add_to_placement_group( self, @@ -469,7 +499,9 @@ def add_to_placement_group( :param placement_group: :class:`BoundPlacementGroup ` or :class:`Network ` :return: :class:`BoundAction ` """ - return self._client.add_to_placement_group(self, placement_group) + return self._client.add_to_placement_group( + self, placement_group=placement_group + ) def remove_from_placement_group(self) -> BoundAction: """Removes a server from a placement group. @@ -481,11 +513,14 @@ def remove_from_placement_group(self) -> BoundAction: class ServersPageResult(NamedTuple): servers: list[BoundServer] - meta: Meta | None + meta: Meta -class ServersClient(ClientEntityBase): - _client: Client +class ServersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/servers" actions: ResourceActionsClient """Servers scoped actions client @@ -495,7 +530,7 @@ class ServersClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/servers") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundServer: """Get a specific server @@ -503,7 +538,7 @@ def get_by_id(self, id: int) -> BoundServer: :param id: int :return: :class:`BoundServer ` """ - response = self._client.request(url=f"/servers/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServer(self, response["server"]) def get_list( @@ -540,7 +575,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/servers", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) ass_servers = [ BoundServer(self, server_data) for server_data in response["servers"] @@ -577,7 +612,7 @@ def get_by_name(self, name: str) -> BoundServer | None: Used to get server by name. :return: :class:`BoundServer ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) # pylint: disable=too-many-branches,too-many-locals def create( @@ -592,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, @@ -617,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) @@ -637,8 +670,6 @@ def create( if location is not None: data["location"] = location.id_or_name - if datacenter is not None: - 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: @@ -667,13 +698,13 @@ def create( data_public_net["ipv6"] = public_net.ipv6.id data["public_net"] = data_public_net - response = self._client.request(url="/servers", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) result = CreateServerResponse( server=BoundServer(self, response["server"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], root_password=response["root_password"], @@ -683,59 +714,40 @@ def create( def get_actions_list( self, server: Server | BoundServer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a server. - - :param server: :class:`BoundServer ` or :class:`Server ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Server. - response = self._client.request( - url=f"/servers/{server.id}/actions", - method="GET", - params=params, + :param server: Server to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{server.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, server: Server | BoundServer, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a server. + """ + Returns all Actions for a Server. - :param server: :class:`BoundServer ` or :class:`Server ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param server: Server to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -765,7 +777,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/servers/{server.id}", + url=f"{self._base_url}/{server.id}", method="PUT", json=data, ) @@ -803,7 +815,7 @@ def get_metrics( params["step"] = step response = self._client.request( - url=f"/servers/{server.id}/metrics", + url=f"{self._base_url}/{server.id}/metrics", method="GET", params=params, ) @@ -817,8 +829,10 @@ def delete(self, server: Server | BoundServer) -> BoundAction: :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ - response = self._client.request(url=f"/servers/{server.id}", method="DELETE") - return BoundAction(self._client.actions, response["action"]) + response = self._client.request( + url=f"{self._base_url}/{server.id}", method="DELETE" + ) + return BoundAction(self._parent.actions, response["action"]) def power_off(self, server: Server | BoundServer) -> BoundAction: """Cuts power to the server. This forcefully stops it without giving the server operating system time to gracefully stop @@ -827,10 +841,10 @@ def power_off(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/poweroff", + url=f"{self._base_url}/{server.id}/actions/poweroff", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def power_on(self, server: Server | BoundServer) -> BoundAction: """Starts a server by turning its power on. @@ -839,10 +853,10 @@ def power_on(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/poweron", + url=f"{self._base_url}/{server.id}/actions/poweron", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reboot(self, server: Server | BoundServer) -> BoundAction: """Reboots a server gracefully by sending an ACPI request. @@ -851,10 +865,10 @@ def reboot(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reboot", + url=f"{self._base_url}/{server.id}/actions/reboot", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset(self, server: Server | BoundServer) -> BoundAction: """Cuts power to a server and starts it again. @@ -863,10 +877,10 @@ def reset(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reset", + url=f"{self._base_url}/{server.id}/actions/reset", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def shutdown(self, server: Server | BoundServer) -> BoundAction: """Shuts down a server gracefully by sending an ACPI shutdown request. @@ -875,10 +889,10 @@ def shutdown(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/shutdown", + url=f"{self._base_url}/{server.id}/actions/shutdown", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: """Resets the root password. Only works for Linux systems that are running the qemu guest agent. @@ -887,11 +901,11 @@ def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: :return: :class:`ResetPasswordResponse ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/reset_password", + url=f"{self._base_url}/{server.id}/actions/reset_password", method="POST", ) return ResetPasswordResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -915,11 +929,11 @@ def change_type( "upgrade_disk": upgrade_disk, } response = self._client.request( - url=f"/servers/{server.id}/actions/change_type", + url=f"{self._base_url}/{server.id}/actions/change_type", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def enable_rescue( self, @@ -942,12 +956,12 @@ def enable_rescue( data.update({"ssh_keys": ssh_keys}) response = self._client.request( - url=f"/servers/{server.id}/actions/enable_rescue", + url=f"{self._base_url}/{server.id}/actions/enable_rescue", method="POST", json=data, ) return EnableRescueResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), root_password=response["root_password"], ) @@ -958,10 +972,10 @@ def disable_rescue(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/disable_rescue", + url=f"{self._base_url}/{server.id}/actions/disable_rescue", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def create_image( self, @@ -993,51 +1007,44 @@ def create_image( data.update({"labels": labels}) response = self._client.request( - url=f"/servers/{server.id}/actions/create_image", + url=f"{self._base_url}/{server.id}/actions/create_image", method="POST", json=data, ) return CreateImageResponse( - action=BoundAction(self._client.actions, response["action"]), - image=BoundImage(self._client.images, response["image"]), + action=BoundAction(self._parent.actions, response["action"]), + image=BoundImage(self._parent.images, response["image"]), ) def rebuild( self, server: Server | BoundServer, image: Image | BoundImage, - *, - return_response: bool = False, - ) -> RebuildResponse | BoundAction: + user_data: str | None = None, + # pylint: disable=unused-argument + **kwargs: Any, + ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. :param server: Server to rebuild :param image: Image to use for the rebuilt server - :param return_response: Whether to return the full response or only the action. + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ data: dict[str, Any] = {"image": image.id_or_name} + if user_data is not None: + data["user_data"] = user_data + response = self._client.request( - url=f"/servers/{server.id}/actions/rebuild", + url=f"{self._base_url}/{server.id}/actions/rebuild", method="POST", json=data, ) - rebuild_response = RebuildResponse( - action=BoundAction(self._client.actions, response["action"]), + return RebuildResponse( + action=BoundAction(self._parent.actions, response["action"]), root_password=response.get("root_password"), ) - if not return_response: - warnings.warn( - "Returning only the 'action' is deprecated, please set the " - "'return_response' keyword argument to 'True' to return the full " - "rebuild response and update your code accordingly.", - DeprecationWarning, - stacklevel=2, - ) - return rebuild_response.action - return rebuild_response - def enable_backup(self, server: Server | BoundServer) -> BoundAction: """Enables and configures the automatic daily backup option for the server. Enabling automatic backups will increase the price of the server by 20%. @@ -1045,10 +1052,10 @@ def enable_backup(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/enable_backup", + url=f"{self._base_url}/{server.id}/actions/enable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def disable_backup(self, server: Server | BoundServer) -> BoundAction: """Disables the automatic backup option and deletes all existing Backups for a Server. @@ -1057,10 +1064,10 @@ def disable_backup(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/disable_backup", + url=f"{self._base_url}/{server.id}/actions/disable_backup", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def attach_iso( self, @@ -1075,11 +1082,11 @@ def attach_iso( """ data: dict[str, Any] = {"iso": iso.id_or_name} response = self._client.request( - url=f"/servers/{server.id}/actions/attach_iso", + url=f"{self._base_url}/{server.id}/actions/attach_iso", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_iso(self, server: Server | BoundServer) -> BoundAction: """Detaches an ISO from a server. @@ -1088,10 +1095,10 @@ def detach_iso(self, server: Server | BoundServer) -> BoundAction: :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/detach_iso", + url=f"{self._base_url}/{server.id}/actions/detach_iso", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_dns_ptr( self, @@ -1110,11 +1117,11 @@ def change_dns_ptr( """ data: dict[str, Any] = {"ip": ip, "dns_ptr": dns_ptr} response = self._client.request( - url=f"/servers/{server.id}/actions/change_dns_ptr", + url=f"{self._base_url}/{server.id}/actions/change_dns_ptr", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_protection( self, @@ -1138,11 +1145,11 @@ def change_protection( data.update({"rebuild": rebuild}) response = self._client.request( - url=f"/servers/{server.id}/actions/change_protection", + url=f"{self._base_url}/{server.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def request_console(self, server: Server | BoundServer) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. @@ -1151,11 +1158,11 @@ def request_console(self, server: Server | BoundServer) -> RequestConsoleRespons :return: :class:`RequestConsoleResponse ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/request_console", + url=f"{self._base_url}/{server.id}/actions/request_console", method="POST", ) return RequestConsoleResponse( - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), wss_url=response["wss_url"], password=response["password"], ) @@ -1166,6 +1173,7 @@ def attach_to_network( network: Network | BoundNetwork, ip: str | None = None, alias_ips: list[str] | None = None, + ip_range: str | None = None, ) -> BoundAction: """Attaches a server to a network @@ -1175,6 +1183,8 @@ def attach_to_network( IP to request to be assigned to this server :param alias_ips: List[str] New alias IPs to set for this server. + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. :return: :class:`BoundAction ` """ data: dict[str, Any] = {"network": network.id} @@ -1182,12 +1192,15 @@ def attach_to_network( data.update({"ip": ip}) if alias_ips is not None: data.update({"alias_ips": alias_ips}) + if ip_range is not None: + data.update({"ip_range": ip_range}) + response = self._client.request( - url=f"/servers/{server.id}/actions/attach_to_network", + url=f"{self._base_url}/{server.id}/actions/attach_to_network", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def detach_from_network( self, @@ -1202,11 +1215,11 @@ def detach_from_network( """ data: dict[str, Any] = {"network": network.id} response = self._client.request( - url=f"/servers/{server.id}/actions/detach_from_network", + url=f"{self._base_url}/{server.id}/actions/detach_from_network", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def change_alias_ips( self, @@ -1224,11 +1237,11 @@ def change_alias_ips( """ data: dict[str, Any] = {"network": network.id, "alias_ips": alias_ips} response = self._client.request( - url=f"/servers/{server.id}/actions/change_alias_ips", + url=f"{self._base_url}/{server.id}/actions/change_alias_ips", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def add_to_placement_group( self, @@ -1241,13 +1254,13 @@ def add_to_placement_group( :param placement_group: :class:`BoundPlacementGroup ` or :class:`Network ` :return: :class:`BoundAction ` """ - data: dict[str, Any] = {"placement_group": str(placement_group.id)} + data: dict[str, Any] = {"placement_group": placement_group.id} response = self._client.request( - url=f"/servers/{server.id}/actions/add_to_placement_group", + url=f"{self._base_url}/{server.id}/actions/add_to_placement_group", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) def remove_from_placement_group(self, server: Server | BoundServer) -> BoundAction: """Removes a server from a placement group. @@ -1256,7 +1269,7 @@ def remove_from_placement_group(self, server: Server | BoundServer) -> BoundActi :return: :class:`BoundAction ` """ response = self._client.request( - url=f"/servers/{server.id}/actions/remove_from_placement_group", + url=f"{self._base_url}/{server.id}/actions/remove_from_placement_group", method="POST", ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index e886e1a1..32ea4506 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,27 +1,45 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal - -from dateutil.parser import isoparse +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 from ..isos import BoundIso + from ..locations import BoundLocation from ..metrics import Metrics - from ..networks import BoundNetwork + from ..networks import BoundNetwork, Network from ..placement_groups import BoundPlacementGroup from ..primary_ips import BoundPrimaryIP, PrimaryIP + from ..rdns import DNSPtr from ..server_types import BoundServerType from ..volumes import BoundVolume from .client import BoundServer +__all__ = [ + "Server", + "ServerProtection", + "CreateServerResponse", + "ResetPasswordResponse", + "EnableRescueResponse", + "RequestConsoleResponse", + "RebuildResponse", + "PublicNetwork", + "PublicNetworkFirewall", + "IPv4Address", + "IPv6Network", + "PrivateNet", + "ServerCreatePublicNetwork", + "GetMetricsResponse", + "MetricsType", +] + + class Server(BaseDomain, DomainIdentityMixin): """Server Domain @@ -36,7 +54,7 @@ class Server(BaseDomain, DomainIdentityMixin): :param public_net: :class:`PublicNetwork ` Public network information. :param server_type: :class:`BoundServerType ` - :param datacenter: :class:`BoundDatacenter ` + :param location: :class:`BoundLocation ` :param image: :class:`BoundImage `, None :param iso: :class:`BoundIso `, None :param rescue_enabled: bool @@ -81,13 +99,14 @@ class Server(BaseDomain, DomainIdentityMixin): """Server Status rebuilding""" STATUS_UNKNOWN = "unknown" """Server Status unknown""" - __slots__ = ( + + __api_properties__ = ( "id", "name", "status", "public_net", "server_type", - "datacenter", + "location", "image", "iso", "rescue_enabled", @@ -104,6 +123,7 @@ class Server(BaseDomain, DomainIdentityMixin): "primary_disk_size", "placement_group", ) + __slots__ = __api_properties__ # pylint: disable=too-many-locals def __init__( @@ -114,7 +134,7 @@ 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, rescue_enabled: bool | None = None, @@ -123,7 +143,7 @@ def __init__( outgoing_traffic: int | None = None, ingoing_traffic: int | None = None, included_traffic: int | None = None, - protection: dict | None = None, + protection: ServerProtection | None = None, labels: dict[str, str] | None = None, volumes: list[BoundVolume] | None = None, private_net: list[PrivateNet] | None = None, @@ -133,10 +153,10 @@ def __init__( self.id = id self.name = name self.status = status - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.public_net = public_net self.server_type = server_type - self.datacenter = datacenter + self.location = location self.image = image self.iso = iso self.rescue_enabled = rescue_enabled @@ -152,6 +172,21 @@ def __init__( self.primary_disk_size = primary_disk_size self.placement_group = placement_group + def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: + """ + Returns the server's network attachment information in the given Network, + and None if no attachment was found. + """ + for o in self.private_net or []: + if o.network.id == network.id: + return o + return None + + +class ServerProtection(TypedDict): + rebuild: bool + delete: bool + class CreateServerResponse(BaseDomain): """Create Server Response Domain @@ -166,7 +201,8 @@ class CreateServerResponse(BaseDomain): The root password of the server if no SSH-Key was given on server creation """ - __slots__ = ("server", "action", "next_actions", "root_password") + __api_properties__ = ("server", "action", "next_actions", "root_password") + __slots__ = __api_properties__ def __init__( self, @@ -190,7 +226,8 @@ class ResetPasswordResponse(BaseDomain): The root password of the server """ - __slots__ = ("action", "root_password") + __api_properties__ = ("action", "root_password") + __slots__ = __api_properties__ def __init__( self, @@ -210,7 +247,8 @@ class EnableRescueResponse(BaseDomain): The root password of the server in the rescue mode """ - __slots__ = ("action", "root_password") + __api_properties__ = ("action", "root_password") + __slots__ = __api_properties__ def __init__( self, @@ -232,7 +270,8 @@ class RequestConsoleResponse(BaseDomain): VNC password to use for this connection. This password only works in combination with a wss_url with valid token. """ - __slots__ = ("action", "wss_url", "password") + __api_properties__ = ("action", "wss_url", "password") + __slots__ = __api_properties__ def __init__( self, @@ -252,7 +291,8 @@ class RebuildResponse(BaseDomain): :param root_password: The root password of the server when not using SSH keys """ - __slots__ = ("action", "root_password") + __api_properties__ = ("action", "root_password") + __slots__ = __api_properties__ def __init__( self, @@ -274,7 +314,7 @@ class PublicNetwork(BaseDomain): :param firewalls: List[:class:`PublicNetworkFirewall `] """ - __slots__ = ( + __api_properties__ = ( "ipv4", "ipv6", "floating_ips", @@ -282,11 +322,12 @@ class PublicNetwork(BaseDomain): "primary_ipv4", "primary_ipv6", ) + __slots__ = __api_properties__ def __init__( self, - ipv4: IPv4Address, - ipv6: IPv6Network, + ipv4: IPv4Address | None, + ipv6: IPv6Network | None, floating_ips: list[BoundFloatingIP], primary_ipv4: BoundPrimaryIP | None, primary_ipv6: BoundPrimaryIP | None, @@ -307,7 +348,8 @@ class PublicNetworkFirewall(BaseDomain): :param status: str """ - __slots__ = ("firewall", "status") + __api_properties__ = ("firewall", "status") + __slots__ = __api_properties__ STATUS_APPLIED = "applied" """Public Network Firewall Status applied""" @@ -334,7 +376,8 @@ class IPv4Address(BaseDomain): DNS PTR for the ip """ - __slots__ = ("ip", "blocked", "dns_ptr") + __api_properties__ = ("ip", "blocked", "dns_ptr") + __slots__ = __api_properties__ def __init__( self, @@ -362,13 +405,14 @@ class IPv6Network(BaseDomain): The network mask """ - __slots__ = ("ip", "blocked", "dns_ptr", "network", "network_mask") + __api_properties__ = ("ip", "blocked", "dns_ptr", "network", "network_mask") + __slots__ = __api_properties__ def __init__( self, ip: str, blocked: bool, - dns_ptr: list, + dns_ptr: list[DNSPtr], ): self.ip = ip self.blocked = blocked @@ -391,7 +435,8 @@ class PrivateNet(BaseDomain): The mac address of the interface on the server """ - __slots__ = ("network", "ip", "alias_ips", "mac_address") + __api_properties__ = ("network", "ip", "alias_ips", "mac_address") + __slots__ = __api_properties__ def __init__( self, @@ -415,7 +460,8 @@ class ServerCreatePublicNetwork(BaseDomain): :param enable_ipv6: bool """ - __slots__ = ("ipv4", "ipv6", "enable_ipv4", "enable_ipv6") + __api_properties__ = ("ipv4", "ipv6", "enable_ipv4", "enable_ipv6") + __slots__ = __api_properties__ def __init__( self, @@ -443,7 +489,8 @@ class GetMetricsResponse(BaseDomain): :param metrics: The Server metrics """ - __slots__ = ("metrics",) + __api_properties__ = ("metrics",) + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/ssh_keys/__init__.py b/hcloud/ssh_keys/__init__.py index 25155906..6aefb42e 100644 --- a/hcloud/ssh_keys/__init__.py +++ b/hcloud/ssh_keys/__init__.py @@ -1,4 +1,11 @@ from __future__ import annotations -from .client import BoundSSHKey, SSHKeysClient, SSHKeysPageResult # noqa: F401 -from .domain import SSHKey # noqa: F401 +from .client import BoundSSHKey, SSHKeysClient, SSHKeysPageResult +from .domain import SSHKey + +__all__ = [ + "BoundSSHKey", + "SSHKey", + "SSHKeysClient", + "SSHKeysPageResult", +] diff --git a/hcloud/ssh_keys/client.py b/hcloud/ssh_keys/client.py index 69c1683d..d88800a1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -1,15 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import Any, NamedTuple -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..core import BoundModelBase, Meta, ResourceClientBase from .domain import SSHKey -if TYPE_CHECKING: - from .._client import Client +__all__ = [ + "BoundSSHKey", + "SSHKeysPageResult", + "SSHKeysClient", +] -class BoundSSHKey(BoundModelBase, SSHKey): +class BoundSSHKey(BoundModelBase[SSHKey], SSHKey): _client: SSHKeysClient model = SSHKey @@ -27,7 +30,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundSSHKey ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes an SSH key. It cannot be used anymore. @@ -38,11 +41,11 @@ def delete(self) -> bool: class SSHKeysPageResult(NamedTuple): ssh_keys: list[BoundSSHKey] - meta: Meta | None + meta: Meta -class SSHKeysClient(ClientEntityBase): - _client: Client +class SSHKeysClient(ResourceClientBase): + _base_url = "/ssh_keys" def get_by_id(self, id: int) -> BoundSSHKey: """Get a specific SSH Key by its ID @@ -50,7 +53,7 @@ def get_by_id(self, id: int) -> BoundSSHKey: :param id: int :return: :class:`BoundSSHKey ` """ - response = self._client.request(url=f"/ssh_keys/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundSSHKey(self, response["ssh_key"]) def get_list( @@ -87,7 +90,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/ssh_keys", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) ssh_keys = [ BoundSSHKey(self, server_data) for server_data in response["ssh_keys"] @@ -124,7 +127,7 @@ def get_by_name(self, name: str) -> BoundSSHKey | None: Used to get ssh key by name. :return: :class:`BoundSSHKey ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def get_by_fingerprint(self, fingerprint: str) -> BoundSSHKey | None: """Get ssh key by fingerprint @@ -133,7 +136,7 @@ def get_by_fingerprint(self, fingerprint: str) -> BoundSSHKey | None: Used to get ssh key by fingerprint. :return: :class:`BoundSSHKey ` """ - return self._get_first_by(fingerprint=fingerprint) + return self._get_first_by(self.get_list, fingerprint=fingerprint) def create( self, @@ -153,7 +156,7 @@ def create( data: dict[str, Any] = {"name": name, "public_key": public_key} if labels is not None: data["labels"] = labels - response = self._client.request(url="/ssh_keys", method="POST", json=data) + response = self._client.request(url=self._base_url, method="POST", json=data) return BoundSSHKey(self, response["ssh_key"]) def update( @@ -177,7 +180,7 @@ def update( if labels is not None: data["labels"] = labels response = self._client.request( - url=f"/ssh_keys/{ssh_key.id}", + url=f"{self._base_url}/{ssh_key.id}", method="PUT", json=data, ) @@ -189,6 +192,6 @@ def delete(self, ssh_key: SSHKey | BoundSSHKey) -> bool: :param ssh_key: :class:`BoundSSHKey ` or :class:`SSHKey ` :return: True """ - self._client.request(url=f"/ssh_keys/{ssh_key.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{ssh_key.id}", method="DELETE") # Return always true, because the API does not return an action for it. When an error occurs a HcloudAPIException will be raised return True diff --git a/hcloud/ssh_keys/domain.py b/hcloud/ssh_keys/domain.py index 09c98cae..c3fc7c60 100644 --- a/hcloud/ssh_keys/domain.py +++ b/hcloud/ssh_keys/domain.py @@ -1,9 +1,11 @@ from __future__ import annotations -from dateutil.parser import isoparse - from ..core import BaseDomain, DomainIdentityMixin +__all__ = [ + "SSHKey", +] + class SSHKey(BaseDomain, DomainIdentityMixin): """SSHKey Domain @@ -22,7 +24,15 @@ class SSHKey(BaseDomain, DomainIdentityMixin): Point in time when the SSH Key was created """ - __slots__ = ("id", "name", "fingerprint", "public_key", "labels", "created") + __api_properties__ = ( + "id", + "name", + "fingerprint", + "public_key", + "labels", + "created", + ) + __slots__ = __api_properties__ def __init__( self, @@ -38,4 +48,4 @@ def __init__( self.fingerprint = fingerprint self.public_key = public_key self.labels = labels - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) diff --git a/hcloud/storage_box_types/__init__.py b/hcloud/storage_box_types/__init__.py new file mode 100644 index 00000000..28d832b0 --- /dev/null +++ b/hcloud/storage_box_types/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBoxType, + StorageBoxTypesClient, + StorageBoxTypesPageResult, +) +from .domain import StorageBoxType + +__all__ = [ + "BoundStorageBoxType", + "StorageBoxType", + "StorageBoxTypesClient", + "StorageBoxTypesPageResult", +] diff --git a/hcloud/storage_box_types/client.py b/hcloud/storage_box_types/client.py new file mode 100644 index 00000000..20e82086 --- /dev/null +++ b/hcloud/storage_box_types/client.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import StorageBoxType + +if TYPE_CHECKING: + from .._client import Client + +__all__ = [ + "BoundStorageBoxType", + "StorageBoxTypesPageResult", + "StorageBoxTypesClient", +] + + +class BoundStorageBoxType(BoundModelBase[StorageBoxType], StorageBoxType): + _client: StorageBoxTypesClient + + model = StorageBoxType + + +class StorageBoxTypesPageResult(NamedTuple): + storage_box_types: list[BoundStorageBoxType] + meta: Meta + + +class StorageBoxTypesClient(ResourceClientBase): + """ + A client for the Storage Box Types API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + _base_url = "/storage_box_types" + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + + def get_by_id(self, id: int) -> BoundStorageBoxType: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-get-a-storage-box-type + + :param id: ID of the Storage Box Type. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id}", + ) + return BoundStorageBoxType(self, response["storage_box_type"]) + + def get_by_name(self, name: str) -> BoundStorageBoxType | None: + """ + Returns a specific Storage Box Type. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + """ + return self._get_first_by(self.get_list, name=name) + + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxTypesPageResult: + """ + Returns a list of Storage Box Types for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return StorageBoxTypesPageResult( + storage_box_types=[ + BoundStorageBoxType(self, o) for o in response["storage_box_types"] + ], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + name: str | None = None, + ) -> list[BoundStorageBoxType]: + """ + Returns all Storage Box Types. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types-list-storage-box-types + + :param name: Name of the Storage Box Type. + """ + return self._iter_pages( + self.get_list, + name=name, + ) diff --git a/hcloud/storage_box_types/domain.py b/hcloud/storage_box_types/domain.py new file mode 100644 index 00000000..b393b153 --- /dev/null +++ b/hcloud/storage_box_types/domain.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import Any + +from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + +__all__ = [ + "StorageBoxType", +] + + +class StorageBoxType(BaseDomain, DomainIdentityMixin): + """ + Storage Box Type Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-types. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "snapshot_limit", + "automatic_snapshot_limit", + "subaccounts_limit", + "size", + "deprecation", + "prices", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + snapshot_limit: int | None = None, + automatic_snapshot_limit: int | None = None, + subaccounts_limit: int | None = None, + size: int | None = None, + prices: list[dict[str, Any]] | None = None, + deprecation: dict[str, Any] | None = None, + ): + self.id = id + self.name = name + self.description = description + self.snapshot_limit = snapshot_limit + self.automatic_snapshot_limit = automatic_snapshot_limit + self.subaccounts_limit = subaccounts_limit + self.size = size + self.prices = prices + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) diff --git a/hcloud/storage_boxes/__init__.py b/hcloud/storage_boxes/__init__.py new file mode 100644 index 00000000..4eb22722 --- /dev/null +++ b/hcloud/storage_boxes/__init__.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + StorageBoxesClient, + StorageBoxesPageResult, + StorageBoxSnapshotsPageResult, + StorageBoxSubaccountsPageResult, +) +from .domain import ( + CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, + DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, + StorageBoxStats, + StorageBoxStatus, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +__all__ = [ + "BoundStorageBox", + "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", + "CreateStorageBoxResponse", + "CreateStorageBoxSnapshotResponse", + "CreateStorageBoxSubaccountResponse", + "DeleteStorageBoxResponse", + "DeleteStorageBoxSnapshotResponse", + "DeleteStorageBoxSubaccountResponse", + "StorageBox", + "StorageBoxAccessSettings", + "StorageBoxesClient", + "StorageBoxesPageResult", + "StorageBoxFoldersResponse", + "StorageBoxSnapshot", + "StorageBoxSnapshotPlan", + "StorageBoxSnapshotsPageResult", + "StorageBoxSnapshotStats", + "StorageBoxStats", + "StorageBoxStatus", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "StorageBoxSubaccountsPageResult", +] diff --git a/hcloud/storage_boxes/client.py b/hcloud/storage_boxes/client.py new file mode 100644 index 00000000..e1ca5c4c --- /dev/null +++ b/hcloud/storage_boxes/client.py @@ -0,0 +1,1672 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation, Location +from ..ssh_keys import BoundSSHKey, SSHKey +from ..storage_box_types import BoundStorageBoxType, StorageBoxType +from .domain import ( + CreateStorageBoxResponse, + CreateStorageBoxSnapshotResponse, + CreateStorageBoxSubaccountResponse, + DeleteStorageBoxResponse, + DeleteStorageBoxSnapshotResponse, + DeleteStorageBoxSubaccountResponse, + StorageBox, + StorageBoxAccessSettings, + StorageBoxFoldersResponse, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSnapshotStats, + StorageBoxStats, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +if TYPE_CHECKING: + from .._client import Client + +__all__ = [ + "BoundStorageBox", + "BoundStorageBoxSnapshot", + "BoundStorageBoxSubaccount", + "StorageBoxesPageResult", + "StorageBoxSnapshotsPageResult", + "StorageBoxSubaccountsPageResult", + "StorageBoxesClient", +] + + +class BoundStorageBox(BoundModelBase[StorageBox], StorageBox): + _client: StorageBoxesClient + + model = StorageBox + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box_type") + if raw is not None: + data["storage_box_type"] = BoundStorageBoxType( + client._parent.storage_box_types, raw + ) + + raw = data.get("location") + if raw is not None: + data["location"] = BoundLocation(client._parent.locations, raw) + + raw = data.get("snapshot_plan") + if raw is not None: + data["snapshot_plan"] = StorageBoxSnapshotPlan.from_dict(raw) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxAccessSettings.from_dict(raw) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxStats.from_dict(raw) + + super().__init__(client, data, complete) + + def get_actions_list( + self, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + + def update( + self, + *, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBox: + """ + Updates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box + + :param name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + return self._client.update( + self, + name=name, + labels=labels, + ) + + def delete(self) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + """ + return self._client.delete(self) + + def get_folders( + self, + *, + path: str | None = None, + ) -> StorageBoxFoldersResponse: + """ + Lists the (sub)folders contained in a Storage Box. + + Files are not part of the response. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box + + :param path: Relative path to list the folders from. + """ + return self._client.get_folders( + self, + path=path, + ) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param delete: Prevents the Storage Box from being deleted. + """ + return self._client.change_protection( + self, + delete=delete, + ) + + def change_type( + self, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box_type: Storage Box Type to change to. + """ + return self._client.change_type( + self, + storage_box_type=storage_box_type, + ) + + def reset_password( + self, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param password: New password. + """ + return self._client.reset_password( + self, + password=password, + ) + + def update_access_settings( + self, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param access_settings: New access settings for the Storage Box. + """ + return self._client.update_access_settings( + self, + access_settings=access_settings, + ) + + def rollback_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param snapshot: Snapshot to rollback to. + """ + return self._client.rollback_snapshot( + self, + snapshot=snapshot, + ) + + def disable_snapshot_plan( + self, + ) -> BoundAction: + """ + Disable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + """ + return self._client.disable_snapshot_plan(self) + + def enable_snapshot_plan( + self, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param snapshot_plan: Snapshot Plan to enable. + """ + return self._client.enable_snapshot_plan( + self, + snapshot_plan=snapshot_plan, + ) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param id: ID of the Snapshot. + """ + return self._client.get_snapshot_by_id(self, id=id) + + def get_snapshot_by_name( + self, + name: str, + ) -> BoundStorageBoxSnapshot | None: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Name of the Snapshot. + """ + return self._client.get_snapshot_by_name(self, name=name) + + def get_snapshot_list( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter wether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_snapshot_list( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def get_snapshot_all( + self, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_snapshot_all( + self, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + + def create_snapshot( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + return self._client.create_snapshot( + self, + description=description, + labels=labels, + ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param id: ID of the Subaccount. + """ + return self._client.get_subaccount_by_id(self, id=id) + + def get_subaccount_by_name( + self, + name: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param name: Name of the Subaccount. + """ + return self._client.get_subaccount_by_name(self, name=name) + + def get_subaccount_by_username( + self, + username: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param username: User name of the Subaccount. + """ + return self._client.get_subaccount_by_username(self, username=username) + + def get_subaccount_list( + self, + *, + name: str | None = None, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_subaccount_list( + self, + name=name, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def get_subaccount_all( + self, + *, + name: str | None = None, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_subaccount_all( + self, + name=name, + username=username, + label_selector=label_selector, + sort=sort, + ) + + def create_subaccount( + self, + *, + name: str | None = None, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param name: Name of the Subaccount. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + return self._client.create_subaccount( + self, + name=name, + home_directory=home_directory, + password=password, + access_settings=access_settings, + description=description, + labels=labels, + ) + + +class BoundStorageBoxSnapshot(BoundModelBase[StorageBoxSnapshot], StorageBoxSnapshot): + _client: StorageBoxesClient + + model = StorageBoxSnapshot + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("stats") + if raw is not None: + data["stats"] = StorageBoxSnapshotStats.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSnapshot: + assert self.data_model.storage_box is not None + assert self.data_model.id is not None + return self._client.get_snapshot_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + def update( + self, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + return self._client.update_snapshot( + self, + description=description, + labels=labels, + ) + + def delete( + self, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + """ + return self._client.delete_snapshot(self) + + +class BoundStorageBoxSubaccount( + BoundModelBase[StorageBoxSubaccount], StorageBoxSubaccount +): + _client: StorageBoxesClient + + model = StorageBoxSubaccount + + def __init__( + self, + client: StorageBoxesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("storage_box") + if raw is not None: + data["storage_box"] = BoundStorageBox( + client, data={"id": raw}, complete=False + ) + + raw = data.get("access_settings") + if raw is not None: + data["access_settings"] = StorageBoxSubaccountAccessSettings.from_dict(raw) + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundStorageBoxSubaccount: + assert self.data_model.storage_box is not None + assert self.data_model.id is not None + return self._client.get_subaccount_by_id( + self.data_model.storage_box, + self.data_model.id, + ) + + def update( + self, + *, + name: str | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param name: Name of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + return self._client.update_subaccount( + self, + name=name, + description=description, + labels=labels, + ) + + def delete( + self, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + """ + return self._client.delete_subaccount(self) + + def change_home_directory( + self, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param home_directory: Home directory for the Subaccount. + """ + return self._client.change_subaccount_home_directory( + self, home_directory=home_directory + ) + + def reset_password( + self, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param password: Password for the Subaccount. + + """ + return self._client.reset_subaccount_password(self, password=password) + + def update_access_settings( + self, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param access_settings: Access settings for the Subaccount. + """ + return self._client.update_subaccount_access_settings( + self, + access_settings=access_settings, + ) + + +class StorageBoxesPageResult(NamedTuple): + storage_boxes: list[BoundStorageBox] + meta: Meta + + +class StorageBoxSnapshotsPageResult(NamedTuple): + snapshots: list[BoundStorageBoxSnapshot] + meta: Meta + + +class StorageBoxSubaccountsPageResult(NamedTuple): + subaccounts: list[BoundStorageBoxSubaccount] + meta: Meta + + +class StorageBoxesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + """ + A client for the Storage Boxes API. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + _base_url = "/storage_boxes" + + actions: ResourceActionsClient + """Storage Boxes scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self._client = client._client_hetzner + self.actions = ResourceActionsClient(self, self._base_url) + + def get_by_id(self, id: int) -> BoundStorageBox: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-get-a-storage-box + + :param id: ID of the Storage Box. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id}", + ) + return BoundStorageBox(self, response["storage_box"]) + + def get_by_name(self, name: str) -> BoundStorageBox | None: + """ + Returns a specific Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + """ + return self._get_first_by(self.get_list, name=name) + + def get_list( + self, + *, + name: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> StorageBoxesPageResult: + """ + Returns a paginated list of Storage Boxes for a specific page. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if label_selector is not None: + params["label_selector"] = label_selector + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return StorageBoxesPageResult( + storage_boxes=[BoundStorageBox(self, o) for o in response["storage_boxes"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + *, + name: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBox]: + """ + Returns all Storage Boxes. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-storage-boxes + + :param name: Name of the Storage Box. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_list, + name=name, + label_selector=label_selector, + sort=sort, + ) + + def create( + self, + *, + name: str, + password: str, + location: BoundLocation | Location, + storage_box_type: BoundStorageBoxType | StorageBoxType, + ssh_keys: list[str | SSHKey | BoundSSHKey] | None = None, + access_settings: StorageBoxAccessSettings | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxResponse: + """ + Creates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-create-a-storage-box + + :param name: Name of the Storage Box. + :param password: Password of the Storage Box. + :param location: Location of the Storage Box. + :param storage_box_type: Type of the Storage Box. + :param ssh_keys: SSH public keys of the Storage Box. + :param access_settings: Access settings of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + data: dict[str, Any] = { + "name": name, + "password": password, + "location": location.id_or_name, + "storage_box_type": storage_box_type.id_or_name, + } + if ssh_keys is not None: + data["ssh_keys"] = [ + o.public_key if isinstance(o, (SSHKey, BoundSSHKey)) else o + for o in ssh_keys + ] + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}", + json=data, + ) + + return CreateStorageBoxResponse( + storage_box=BoundStorageBox(self, response["storage_box"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update( + self, + storage_box: BoundStorageBox | StorageBox, + *, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBox: + """ + Updates a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-update-a-storage-box + + :param storage_box: Storage Box to update. + :param name: Name of the Storage Box. + :param labels: User-defined labels (key/value pairs) for the Storage Box. + """ + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{storage_box.id}", + json=data, + ) + + return BoundStorageBox(self, response["storage_box"]) + + def delete( + self, + storage_box: BoundStorageBox | StorageBox, + ) -> DeleteStorageBoxResponse: + """ + Deletes a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-delete-a-storage-box + + :param storage_box: Storage Box to delete. + """ + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{storage_box.id}", + ) + + return DeleteStorageBoxResponse( + action=BoundAction(self._parent.actions, response["action"]) + ) + + def get_folders( + self, + storage_box: BoundStorageBox | StorageBox, + *, + path: str | None = None, + ) -> StorageBoxFoldersResponse: + """ + Lists the (sub)folders contained in a Storage Box. + + Files are not part of the response. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes-list-folders-of-a-storage-box + + :param storage_box: Storage Box to list the folders from. + :param path: Relative path to list the folders from. + """ + params: dict[str, Any] = {} + if path is not None: + params["path"] = path + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/folders", + params=params, + ) + + return StorageBoxFoldersResponse(folders=response["folders"]) + + def get_actions_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{storage_box.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + storage_box: StorageBox | BoundStorageBox, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-list-actions-for-a-storage-box + + :param storage_box: Storage Box to get the Actions for. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + storage_box, + status=status, + sort=sort, + ) + + def change_protection( + self, + storage_box: StorageBox | BoundStorageBox, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-protection + + :param storage_box: Storage Box to update. + :param delete: Prevents the Storage Box from being deleted. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_type( + self, + storage_box: StorageBox | BoundStorageBox, + storage_box_type: StorageBoxType | BoundStorageBoxType, + ) -> BoundAction: + """ + Changes the type of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-change-type + + :param storage_box: Storage Box to update. + :param storage_box_type: Storage Box Type to change to. + """ + data: dict[str, Any] = { + "storage_box_type": storage_box_type.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/change_type", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_password( + self, + storage_box: StorageBox | BoundStorageBox, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-reset-password + + :param storage_box: Storage Box to update. + :param password: New password. + """ + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/reset_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_access_settings( + self, + storage_box: StorageBox | BoundStorageBox, + access_settings: StorageBoxAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-update-access-settings + + :param storage_box: Storage Box to update. + :param access_settings: New access settings for the Storage Box. + """ + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def rollback_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> BoundAction: + """ + Rollback the Storage Box to the given snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-rollback-snapshot + + :param storage_box: Storage Box to update. + :param snapshot: Snapshot to rollback to. + """ + data: dict[str, Any] = { + "snapshot": snapshot.id_or_name, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/rollback_snapshot", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def disable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + ) -> BoundAction: + """ + Disable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-disable-snapshot-plan + + :param storage_box: Storage Box to update. + """ + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/disable_snapshot_plan", + ) + return BoundAction(self._parent.actions, response["action"]) + + def enable_snapshot_plan( + self, + storage_box: StorageBox | BoundStorageBox, + snapshot_plan: StorageBoxSnapshotPlan, + ) -> BoundAction: + """ + Enable the snapshot plan of a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-actions-enable-snapshot-plan + + :param storage_box: Storage Box to update. + :param snapshot_plan: Snapshot Plan to enable. + """ + data: dict[str, Any] = snapshot_plan.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/actions/enable_snapshot_plan", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + # Snapshots + ########################################################################### + + def get_snapshot_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSnapshot: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-get-a-snapshot + + :param storage_box: Storage Box to get the Snapshot from. + :param id: ID of the Snapshot. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots/{id}", + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def get_snapshot_by_name( + self, + storage_box: StorageBox | BoundStorageBox, + name: str, + ) -> BoundStorageBoxSnapshot | None: + """ + Returns a single Snapshot from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshot from. + :param name: Name of the Snapshot. + """ + return self._get_first_by(self.get_snapshot_list, storage_box, name=name) + + def get_snapshot_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSnapshotsPageResult: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if is_automatic is not None: + params["is_automatic"] = is_automatic + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/snapshots", + params=params, + ) + return StorageBoxSnapshotsPageResult( + snapshots=[ + BoundStorageBoxSnapshot(self, item) for item in response["snapshots"] + ], + meta=Meta.parse_meta(response), + ) + + def get_snapshot_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + is_automatic: bool | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSnapshot]: + """ + Returns all Snapshots for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-list-snapshots + + :param storage_box: Storage Box to get the Snapshots from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param is_automatic: Filter whether the snapshot was made by a Snapshot Plan. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_snapshot_list( + storage_box, + name=name, + is_automatic=is_automatic, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_snapshot( + self, + storage_box: StorageBox | BoundStorageBox, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSnapshotResponse: + """ + Creates a Snapshot of the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-create-a-snapshot + + :param storage_box: Storage Box to create a Snapshot from. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/snapshots", + json=data, + ) + return CreateStorageBoxSnapshotResponse( + snapshot=BoundStorageBoxSnapshot( + self, + response["snapshot"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSnapshot: + """ + Updates a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-update-a-snapshot + + :param snapshot: Storage Box Snapshot to update. + :param description: Description of the Snapshot. + :param labels: User-defined labels (key/value pairs) for the Snapshot. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + data: dict[str, Any] = {} + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + json=data, + ) + return BoundStorageBoxSnapshot(self, response["snapshot"]) + + def delete_snapshot( + self, + snapshot: StorageBoxSnapshot | BoundStorageBoxSnapshot, + ) -> DeleteStorageBoxSnapshotResponse: + """ + Deletes a Storage Box Snapshot. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-snapshots-delete-a-snapshot + + :param snapshot: Storage Box Snapshot to delete. + """ + if snapshot.storage_box is None: + raise ValueError("snapshot storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{snapshot.storage_box.id}/snapshots/{snapshot.id}", + ) + return DeleteStorageBoxSnapshotResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + # Subaccounts + ########################################################################### + + def get_subaccount_by_id( + self, + storage_box: StorageBox | BoundStorageBox, + id: int, + ) -> BoundStorageBoxSubaccount: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-get-a-subaccount + + :param storage_box: Storage Box to get the Subaccount from. + :param id: ID of the Subaccount. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts/{id}", + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def get_subaccount_by_name( + self, + storage_box: StorageBox | BoundStorageBox, + name: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param name: Name of the Subaccount. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + name=name, + ) + + def get_subaccount_by_username( + self, + storage_box: StorageBox | BoundStorageBox, + username: str, + ) -> BoundStorageBoxSubaccount | None: + """ + Returns a single Subaccount from a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param username: User name of the Subaccount. + """ + return self._get_first_by( + self.get_subaccount_list, + storage_box, + username=username, + ) + + def get_subaccount_list( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> StorageBoxSubaccountsPageResult: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if username is not None: + params["username"] = username + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + params=params, + ) + return StorageBoxSubaccountsPageResult( + subaccounts=[ + BoundStorageBoxSubaccount(self, item) + for item in response["subaccounts"] + ], + meta=Meta.parse_meta(response), + ) + + def get_subaccount_all( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + username: str | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundStorageBoxSubaccount]: + """ + Returns all Subaccounts for a Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-list-subaccounts + + :param storage_box: Storage Box to get the Subaccount from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param username: Filter resources by their username. The response will only contain the resources matching exactly the specified username. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + # The endpoint does not have pagination, forward to the list method. + result, _ = self.get_subaccount_list( + storage_box, + name=name, + username=username, + label_selector=label_selector, + sort=sort, + ) + return result + + def create_subaccount( + self, + storage_box: StorageBox | BoundStorageBox, + *, + name: str | None = None, + home_directory: str, + password: str, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateStorageBoxSubaccountResponse: + """ + Creates a Subaccount for the Storage Box. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-create-a-subaccount + + :param storage_box: Storage Box to create a Subaccount for. + :param name: Name of the Subaccount. + :param home_directory: Home directory of the Subaccount. + :param password: Password of the Subaccount. + :param access_settings: Access settings of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + data: dict[str, Any] = { + "home_directory": home_directory, + "password": password, + } + if name is not None: + data["name"] = name + if access_settings is not None: + data["access_settings"] = access_settings.to_payload() + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{storage_box.id}/subaccounts", + json=data, + ) + return CreateStorageBoxSubaccountResponse( + subaccount=BoundStorageBoxSubaccount( + self, + response["subaccount"], + # API only returns a partial object. + complete=False, + ), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + *, + name: str | None = None, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundStorageBoxSubaccount: + """ + Updates a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-update-a-subaccount + + :param subaccount: Storage Box Subaccount to update. + :param name: Name of the Subaccount. + :param description: Description of the Subaccount. + :param labels: User-defined labels (key/value pairs) for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = {} + if name is not None: + data["name"] = name + if description is not None: + data["description"] = description + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + json=data, + ) + return BoundStorageBoxSubaccount(self, response["subaccount"]) + + def delete_subaccount( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + ) -> DeleteStorageBoxSubaccountResponse: + """ + Deletes a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts-delete-a-subaccount + + :param subaccount: Storage Box Subaccount to delete. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}", + ) + return DeleteStorageBoxSubaccountResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def change_subaccount_home_directory( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + home_directory: str, + ) -> BoundAction: + """ + Change the home directory of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-change-home-directory + + :param subaccount: Storage Box Subaccount to update. + :param home_directory: Home directory for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "home_directory": home_directory, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/change_home_directory", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def reset_subaccount_password( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + password: str, + ) -> BoundAction: + """ + Reset the password of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-reset-password + + :param subaccount: Storage Box Subaccount to update. + :param password: Password for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = { + "password": password, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/reset_subaccount_password", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_subaccount_access_settings( + self, + subaccount: StorageBoxSubaccount | BoundStorageBoxSubaccount, + access_settings: StorageBoxSubaccountAccessSettings, + ) -> BoundAction: + """ + Update the access settings of a Storage Box Subaccount. + + See https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccount-actions-update-access-settings + + :param subaccount: Storage Box Subaccount to update. + :param access_settings: Access settings for the Subaccount. + """ + if subaccount.storage_box is None: + raise ValueError("subaccount storage_box property is none") + + data: dict[str, Any] = access_settings.to_payload() + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{subaccount.storage_box.id}/subaccounts/{subaccount.id}/actions/update_access_settings", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/storage_boxes/domain.py b/hcloud/storage_boxes/domain.py new file mode 100644 index 00000000..8d2237a2 --- /dev/null +++ b/hcloud/storage_boxes/domain.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal + +from ..actions import BoundAction +from ..core import BaseDomain, DomainIdentityMixin +from ..locations import BoundLocation, Location +from ..storage_box_types import BoundStorageBoxType, StorageBoxType + +if TYPE_CHECKING: + from .client import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + ) + +__all__ = [ + "StorageBox", + "StorageBoxAccessSettings", + "StorageBoxStats", + "StorageBoxSnapshotPlan", + "CreateStorageBoxResponse", + "DeleteStorageBoxResponse", + "StorageBoxFoldersResponse", + "StorageBoxSnapshot", + "StorageBoxSnapshotStats", + "CreateStorageBoxSnapshotResponse", + "DeleteStorageBoxSnapshotResponse", + "StorageBoxSubaccount", + "StorageBoxSubaccountAccessSettings", + "CreateStorageBoxSubaccountResponse", + "DeleteStorageBoxSubaccountResponse", + "StorageBoxStatus", +] + + +StorageBoxStatus = Literal[ + "active", + "initializing", + "locked", +] + + +class StorageBox(BaseDomain, DomainIdentityMixin): + """ + Storage Box Domain. + + See https://docs.hetzner.cloud/reference/hetzner#storage-boxes. + """ + + STATUS_ACTIVE = "active" + STATUS_INITIALIZING = "initializing" + STATUS_LOCKED = "locked" + + __api_properties__ = ( + "id", + "name", + "storage_box_type", + "location", + "system", + "server", + "username", + "labels", + "protection", + "snapshot_plan", + "access_settings", + "stats", + "status", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + storage_box_type: BoundStorageBoxType | StorageBoxType | None = None, + location: BoundLocation | Location | None = None, + system: str | None = None, + server: str | None = None, + username: str | None = None, + labels: dict[str, str] | None = None, + protection: dict[str, bool] | None = None, + snapshot_plan: StorageBoxSnapshotPlan | None = None, + access_settings: StorageBoxAccessSettings | None = None, + stats: StorageBoxStats | None = None, + status: StorageBoxStatus | None = None, + created: str | None = None, + ): + self.id = id + self.name = name + self.storage_box_type = storage_box_type + self.location = location + self.system = system + self.server = server + self.username = username + self.labels = labels + self.protection = protection + self.snapshot_plan = snapshot_plan + self.access_settings = access_settings + self.stats = stats + self.status = status + self.created = self._parse_datetime(created) + + +class StorageBoxAccessSettings(BaseDomain): + """ + Storage Box Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "zfs_enabled", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + zfs_enabled: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.zfs_enabled = zfs_enabled + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.zfs_enabled is not None: + payload["zfs_enabled"] = self.zfs_enabled + return payload + + +class StorageBoxStats(BaseDomain): + """ + Storage Box Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_data", + "size_snapshots", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int | None = None, + size_data: int | None = None, + size_snapshots: int | None = None, + ): + self.size = size + self.size_data = size_data + self.size_snapshots = size_snapshots + + +class StorageBoxSnapshotPlan(BaseDomain): + """ + Storage Box Snapshot Plan Domain. + """ + + __api_properties__ = ( + "max_snapshots", + "hour", + "minute", + "day_of_week", + "day_of_month", + ) + __slots__ = __api_properties__ + + def __init__( + self, + max_snapshots: int, + hour: int, + minute: int, + day_of_week: int | None = None, + day_of_month: int | None = None, + ): + self.max_snapshots = max_snapshots + self.hour = hour + self.minute = minute + self.day_of_week = day_of_week + self.day_of_month = day_of_month + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "max_snapshots": self.max_snapshots, + "hour": self.hour, + "minute": self.minute, + "day_of_week": self.day_of_week, # API default is null + "day_of_month": self.day_of_month, # API default is null + } + + return payload + + +class CreateStorageBoxResponse(BaseDomain): + """ + Create Storage Box Response Domain. + """ + + __api_properties__ = ( + "storage_box", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + storage_box: BoundStorageBox, + action: BoundAction, + ): + self.storage_box = storage_box + self.action = action + + +class DeleteStorageBoxResponse(BaseDomain): + """ + Delete Storage Box Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class StorageBoxFoldersResponse(BaseDomain): + """ + Storage Box Folders Response Domain. + """ + + __api_properties__ = ("folders",) + __slots__ = __api_properties__ + + def __init__( + self, + folders: list[str], + ): + self.folders = folders + + +# Snapshots +############################################################################### + + +class StorageBoxSnapshot(BaseDomain, DomainIdentityMixin): + """ + Storage Box Snapshot Domain. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "is_automatic", + "labels", + "storage_box", + "created", + "stats", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + is_automatic: bool | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + stats: StorageBoxSnapshotStats | None = None, + ): + self.id = id + self.name = name + self.description = description + self.is_automatic = is_automatic + self.labels = labels + self.storage_box = storage_box + self.created = self._parse_datetime(created) + self.stats = stats + + +class StorageBoxSnapshotStats(BaseDomain): + """ + Storage Box Snapshot Stats Domain. + """ + + __api_properties__ = ( + "size", + "size_filesystem", + ) + __slots__ = __api_properties__ + + def __init__( + self, + size: int, + size_filesystem: int, + ): + self.size = size + self.size_filesystem = size_filesystem + + +class CreateStorageBoxSnapshotResponse(BaseDomain): + """ + Create Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ( + "snapshot", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + snapshot: BoundStorageBoxSnapshot, + action: BoundAction, + ): + self.snapshot = snapshot + self.action = action + + +class DeleteStorageBoxSnapshotResponse(BaseDomain): + """ + Delete Storage Box Snapshot Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +# Subaccounts +############################################################################### + + +class StorageBoxSubaccount(BaseDomain, DomainIdentityMixin): + """ + Storage Box Subaccount Domain. + """ + + __api_properties__ = ( + "id", + "name", + "username", + "description", + "server", + "home_directory", + "access_settings", + "labels", + "storage_box", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + username: str | None = None, + description: str | None = None, + server: str | None = None, + home_directory: str | None = None, + access_settings: StorageBoxSubaccountAccessSettings | None = None, + labels: dict[str, str] | None = None, + storage_box: BoundStorageBox | StorageBox | None = None, + created: str | None = None, + ): + self.id = id + self.name = name + self.username = username + self.description = description + self.server = server + self.home_directory = home_directory + self.access_settings = access_settings + self.labels = labels + self.storage_box = storage_box + self.created = self._parse_datetime(created) + + +class StorageBoxSubaccountAccessSettings(BaseDomain): + """ + Storage Box Subaccount Access Settings Domain. + """ + + __api_properties__ = ( + "reachable_externally", + "samba_enabled", + "ssh_enabled", + "webdav_enabled", + "readonly", + ) + __slots__ = __api_properties__ + + def __init__( + self, + reachable_externally: bool | None = None, + samba_enabled: bool | None = None, + ssh_enabled: bool | None = None, + webdav_enabled: bool | None = None, + readonly: bool | None = None, + ): + self.reachable_externally = reachable_externally + self.samba_enabled = samba_enabled + self.ssh_enabled = ssh_enabled + self.webdav_enabled = webdav_enabled + self.readonly = readonly + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + if self.reachable_externally is not None: + payload["reachable_externally"] = self.reachable_externally + if self.samba_enabled is not None: + payload["samba_enabled"] = self.samba_enabled + if self.ssh_enabled is not None: + payload["ssh_enabled"] = self.ssh_enabled + if self.webdav_enabled is not None: + payload["webdav_enabled"] = self.webdav_enabled + if self.readonly is not None: + payload["readonly"] = self.readonly + return payload + + +class CreateStorageBoxSubaccountResponse(BaseDomain): + """ + Create Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ( + "subaccount", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + subaccount: BoundStorageBoxSubaccount, + action: BoundAction, + ): + self.subaccount = subaccount + self.action = action + + +class DeleteStorageBoxSubaccountResponse(BaseDomain): + """ + Delete Storage Box Subaccount Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/hcloud/volumes/__init__.py b/hcloud/volumes/__init__.py index dc8ccbb6..17226236 100644 --- a/hcloud/volumes/__init__.py +++ b/hcloud/volumes/__init__.py @@ -1,4 +1,13 @@ from __future__ import annotations -from .client import BoundVolume, VolumesClient, VolumesPageResult # noqa: F401 -from .domain import CreateVolumeResponse, Volume # noqa: F401 +from .client import BoundVolume, VolumesClient, VolumesPageResult +from .domain import CreateVolumeResponse, Volume, VolumeProtection + +__all__ = [ + "BoundVolume", + "CreateVolumeResponse", + "Volume", + "VolumeProtection", + "VolumesClient", + "VolumesPageResult", +] diff --git a/hcloud/volumes/client.py b/hcloud/volumes/client.py index a4709748..a4155cf2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -2,8 +2,15 @@ from typing import TYPE_CHECKING, Any, NamedTuple -from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient -from ..core import BoundModelBase, ClientEntityBase, Meta +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase from ..locations import BoundLocation from .domain import CreateVolumeResponse, Volume @@ -13,15 +20,27 @@ from ..servers import BoundServer, Server -class BoundVolume(BoundModelBase, Volume): +__all__ = [ + "BoundVolume", + "VolumesPageResult", + "VolumesClient", +] + + +class BoundVolume(BoundModelBase[Volume], Volume): _client: VolumesClient model = Volume - def __init__(self, client: VolumesClient, data: dict, complete: bool = True): + def __init__( + self, + client: VolumesClient, + data: dict[str, Any], + complete: bool = True, + ): location = data.get("location") if location is not None: - data["location"] = BoundLocation(client._client.locations, location) + data["location"] = BoundLocation(client._parent.locations, location) # pylint: disable=import-outside-toplevel from ..servers import BoundServer @@ -29,45 +48,41 @@ def __init__(self, client: VolumesClient, data: dict, complete: bool = True): server = data.get("server") if server is not None: data["server"] = BoundServer( - client._client.servers, {"id": server}, complete=False + client._parent.servers, {"id": server}, complete=False ) super().__init__(client, data, complete) def get_actions_list( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a volume. + """ + Returns a paginated list of Actions for a Volume. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return self._client.get_actions_list(self, status, sort, page, per_page) + return self._client.get_actions_list( + self, status=status, sort=sort, page=page, per_page=per_page + ) def get_actions( self, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a volume. + """ + Returns all Actions for a Volume. - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ - return self._client.get_actions(self, status, sort) + return self._client.get_actions(self, status=status, sort=sort) def update( self, @@ -82,7 +97,7 @@ def update( User-defined labels (key-value pairs) :return: :class:`BoundAction ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) def delete(self) -> bool: """Deletes a volume. All volume data is irreversibly destroyed. The volume must not be attached to a server and it must not have delete protection enabled. @@ -102,7 +117,7 @@ def attach( :param automount: boolean :return: :class:`BoundAction ` """ - return self._client.attach(self, server, automount) + return self._client.attach(self, server=server, automount=automount) def detach(self) -> BoundAction: """Detaches a volume from the server it’s attached to. You may attach it to a server again at a later time. @@ -118,7 +133,7 @@ def resize(self, size: int) -> BoundAction: New volume size in GB (must be greater than current size) :return: :class:`BoundAction ` """ - return self._client.resize(self, size) + return self._client.resize(self, size=size) def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of a volume. @@ -127,16 +142,19 @@ def change_protection(self, delete: bool | None = None) -> BoundAction: If True, prevents the volume from being deleted :return: :class:`BoundAction ` """ - return self._client.change_protection(self, delete) + return self._client.change_protection(self, delete=delete) class VolumesPageResult(NamedTuple): volumes: list[BoundVolume] - meta: Meta | None + meta: Meta -class VolumesClient(ClientEntityBase): - _client: Client +class VolumesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/volumes" actions: ResourceActionsClient """Volumes scoped actions client @@ -146,7 +164,7 @@ class VolumesClient(ClientEntityBase): def __init__(self, client: Client): super().__init__(client) - self.actions = ResourceActionsClient(client, "/volumes") + self.actions = ResourceActionsClient(client, self._base_url) def get_by_id(self, id: int) -> BoundVolume: """Get a specific volume by its id @@ -154,7 +172,7 @@ def get_by_id(self, id: int) -> BoundVolume: :param id: int :return: :class:`BoundVolume ` """ - response = self._client.request(url=f"/volumes/{id}", method="GET") + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundVolume(self, response["volume"]) def get_list( @@ -191,7 +209,7 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/volumes", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) volumes = [ BoundVolume(self, volume_data) for volume_data in response["volumes"] ] @@ -223,7 +241,7 @@ def get_by_name(self, name: str) -> BoundVolume | None: Used to get volume by name. :return: :class:`BoundVolume ` """ - return self._get_first_by(name=name) + return self._get_first_by(self.get_list, name=name) def create( self, @@ -271,13 +289,13 @@ def create( if format is not None: data["format"] = format - response = self._client.request(url="/volumes", json=data, method="POST") + response = self._client.request(url=self._base_url, json=data, method="POST") result = CreateVolumeResponse( volume=BoundVolume(self, response["volume"]), - action=BoundAction(self._client.actions, response["action"]), + action=BoundAction(self._parent.actions, response["action"]), next_actions=[ - BoundAction(self._client.actions, action) + BoundAction(self._parent.actions, action) for action in response["next_actions"] ], ) @@ -286,59 +304,40 @@ def create( def get_actions_list( self, volume: Volume | BoundVolume, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, page: int | None = None, per_page: int | None = None, ) -> ActionsPageResult: - """Returns all action objects for a volume. - - :param volume: :class:`BoundVolume ` or :class:`Volume ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :param page: int (optional) - Specifies the page to fetch - :param per_page: int (optional) - Specifies how many results are returned by page - :return: (List[:class:`BoundAction `], :class:`Meta `) """ - params: dict[str, Any] = {} - if status is not None: - params["status"] = status - if sort is not None: - params["sort"] = sort - if page is not None: - params["page"] = page - if per_page is not None: - params["per_page"] = per_page + Returns a paginated list of Actions for a Volume. - response = self._client.request( - url=f"/volumes/{volume.id}/actions", - method="GET", - params=params, + :param volume: Volume to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{volume.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return ActionsPageResult(actions, Meta.parse_meta(response)) def get_actions( self, volume: Volume | BoundVolume, - status: list[str] | None = None, - sort: list[str] | None = None, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, ) -> list[BoundAction]: - """Returns all action objects for a volume. + """ + Returns all Actions for a Volume. - :param volume: :class:`BoundVolume ` or :class:`Volume ` - :param status: List[str] (optional) - Response will have only actions with specified statuses. Choices: `running` `success` `error` - :param sort: List[str] (optional) - Specify how the results are sorted. Choices: `id` `id:asc` `id:desc` `command` `command:asc` `command:desc` `status` `status:asc` `status:desc` `progress` `progress:asc` `progress:desc` `started` `started:asc` `started:desc` `finished` `finished:asc` `finished:desc` - :return: List[:class:`BoundAction `] + :param volume: Volume to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. """ return self._iter_pages( self.get_actions_list, @@ -368,7 +367,7 @@ def update( if labels is not None: data.update({"labels": labels}) response = self._client.request( - url=f"/volumes/{volume.id}", + url=f"{self._base_url}/{volume.id}", method="PUT", json=data, ) @@ -380,7 +379,7 @@ def delete(self, volume: Volume | BoundVolume) -> bool: :param volume: :class:`BoundVolume ` or :class:`Volume ` :return: boolean """ - self._client.request(url=f"/volumes/{volume.id}", method="DELETE") + self._client.request(url=f"{self._base_url}/{volume.id}", method="DELETE") return True def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: @@ -392,11 +391,11 @@ def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: :return: :class:`BoundAction ` """ data = self._client.request( - url=f"/volumes/{volume.id}/actions/resize", + url=f"{self._base_url}/{volume.id}/actions/resize", json={"size": size}, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def attach( self, @@ -416,11 +415,11 @@ def attach( data["automount"] = automount data = self._client.request( - url=f"/volumes/{volume.id}/actions/attach", + url=f"{self._base_url}/{volume.id}/actions/attach", json=data, method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def detach(self, volume: Volume | BoundVolume) -> BoundAction: """Detaches a volume from the server it’s attached to. You may attach it to a server again at a later time. @@ -429,10 +428,10 @@ def detach(self, volume: Volume | BoundVolume) -> BoundAction: :return: :class:`BoundAction ` """ data = self._client.request( - url=f"/volumes/{volume.id}/actions/detach", + url=f"{self._base_url}/{volume.id}/actions/detach", method="POST", ) - return BoundAction(self._client.actions, data["action"]) + return BoundAction(self._parent.actions, data["action"]) def change_protection( self, @@ -451,8 +450,8 @@ def change_protection( data.update({"delete": delete}) response = self._client.request( - url=f"/volumes/{volume.id}/actions/change_protection", + url=f"{self._base_url}/{volume.id}/actions/change_protection", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py index fb793ef6..2ed33338 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -1,8 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from dateutil.parser import isoparse +from typing import TYPE_CHECKING, TypedDict from ..core import BaseDomain, DomainIdentityMixin @@ -12,6 +10,12 @@ from ..servers import BoundServer, Server from .client import BoundVolume +__all__ = [ + "Volume", + "VolumeProtection", + "CreateVolumeResponse", +] + class Volume(BaseDomain, DomainIdentityMixin): """Volume Domain @@ -45,7 +49,7 @@ class Volume(BaseDomain, DomainIdentityMixin): STATUS_AVAILABLE = "available" """Volume Status available""" - __slots__ = ( + __api_properties__ = ( "id", "name", "server", @@ -58,6 +62,7 @@ class Volume(BaseDomain, DomainIdentityMixin): "status", "created", ) + __slots__ = __api_properties__ def __init__( self, @@ -69,14 +74,14 @@ def __init__( size: int | None = None, linux_device: str | None = None, format: str | None = None, - protection: dict | None = None, + protection: VolumeProtection | None = None, labels: dict[str, str] | None = None, status: str | None = None, ): self.id = id self.name = name self.server = server - self.created = isoparse(created) if created else None + self.created = self._parse_datetime(created) self.location = location self.size = size self.linux_device = linux_device @@ -86,6 +91,10 @@ def __init__( self.status = status +class VolumeProtection(TypedDict): + delete: bool + + class CreateVolumeResponse(BaseDomain): """Create Volume Response Domain @@ -97,7 +106,8 @@ class CreateVolumeResponse(BaseDomain): List of actions that are performed after the creation, like attaching to a server """ - __slots__ = ("volume", "action", "next_actions") + __api_properties__ = ("volume", "action", "next_actions") + __slots__ = __api_properties__ def __init__( self, diff --git a/hcloud/zones/__init__.py b/hcloud/zones/__init__.py new file mode 100644 index 00000000..4cbee7fb --- /dev/null +++ b/hcloud/zones/__init__.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from .client import ( + BoundZone, + BoundZoneRRSet, + ZoneRRSetsPageResult, + ZonesClient, + ZonesPageResult, +) +from .domain import ( + CreateZoneResponse, + CreateZoneRRSetResponse, + DeleteZoneResponse, + DeleteZoneRRSetResponse, + ExportZonefileResponse, + Zone, + ZoneAuthoritativeNameservers, + ZoneMode, + ZonePrimaryNameserver, + ZoneProtection, + ZoneRecord, + ZoneRegistrar, + ZoneRRSet, + ZoneRRSetProtection, + ZoneStatus, +) + +__all__ = [ + "BoundZone", + "BoundZoneRRSet", + "CreateZoneResponse", + "Zone", + "ZoneAuthoritativeNameservers", + "ZonePrimaryNameserver", + "ZoneRecord", + "ZoneRRSet", + "ZonesClient", + "ZonesPageResult", + "DeleteZoneRRSetResponse", + "ZoneRRSetProtection", + "DeleteZoneResponse", + "ZoneRegistrar", + "ZoneMode", + "ZoneRRSetsPageResult", + "ZoneProtection", + "ExportZonefileResponse", + "CreateZoneRRSetResponse", + "ZoneStatus", +] diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py new file mode 100644 index 00000000..2f680286 --- /dev/null +++ b/hcloud/zones/client.py @@ -0,0 +1,1295 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import ( + CreateZoneResponse, + CreateZoneRRSetResponse, + DeleteZoneResponse, + DeleteZoneRRSetResponse, + ExportZonefileResponse, + Zone, + ZoneAuthoritativeNameservers, + ZoneMode, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZoneRRSetType, +) + +if TYPE_CHECKING: + from .._client import Client + +__all__ = [ + "BoundZone", + "BoundZoneRRSet", + "ZonesPageResult", + "ZoneRRSetsPageResult", + "ZonesClient", +] + + +class BoundZone(BoundModelBase[Zone], Zone): + _client: ZonesClient + + model = Zone + + def __init__( + self, + client: ZonesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("primary_nameservers") + if raw is not None: + data["primary_nameservers"] = [ + ZonePrimaryNameserver.from_dict(o) for o in raw + ] + + raw = data.get("authoritative_nameservers") + if raw: + data["authoritative_nameservers"] = ZoneAuthoritativeNameservers.from_dict( + raw + ) + + super().__init__(client, data, complete) + + def update( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + return self._client.update(self, labels=labels) + + def delete(self) -> DeleteZoneResponse: + """ + Deletes the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + """ + return self._client.delete(self) + + def export_zonefile(self) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + """ + return self._client.export_zonefile(self) + + def get_actions_list( + self, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zonefile: Zone file to import. + """ + return self._client.import_zonefile(self, zonefile=zonefile) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param delete: Prevents the Zone from being deleted. + """ + return self._client.change_protection(self, delete=delete) + + def change_ttl( + self, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param ttl: Default Time To Live (TTL) of the Zone. + """ + return self._client.change_ttl(self, ttl=ttl) + + def change_primary_nameservers( + self, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param primary_nameservers: Primary nameservers of the Zone. + """ + return self._client.change_primary_nameservers( + self, + primary_nameservers=primary_nameservers, + ) + + def get_rrset( + self, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + """ + return self._client.get_rrset(self, name=name, type=type) + + def get_rrset_list( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + return self._client.get_rrset_list( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_rrset_all( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._client.get_rrset_all( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + """ + return self._client.create_rrset( + self, + name=name, + type=type, + ttl=ttl, + labels=labels, + records=records, + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + return self._client.update_rrset(rrset=rrset, labels=labels) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + """ + return self._client.delete_rrset(rrset=rrset) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + """ + return self._client.change_rrset_protection(rrset=rrset, change=change) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + """ + return self._client.change_rrset_ttl(rrset=rrset, ttl=ttl) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + """ + return self._client.add_rrset_records(rrset=rrset, records=records, ttl=ttl) + + def update_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to update in the RRSet. + """ + return self._client.update_rrset_records(rrset=rrset, records=records) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + """ + return self._client.remove_rrset_records(rrset=rrset, records=records) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + """ + return self._client.set_rrset_records(rrset=rrset, records=records) + + +class BoundZoneRRSet(BoundModelBase[ZoneRRSet], ZoneRRSet): + _client: ZonesClient + + model = ZoneRRSet + + def __init__( + self, + client: ZonesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("zone") + if raw is not None: + data["zone"] = BoundZone(client, data={"id": raw}, complete=False) + + raw = data.get("records") + if raw is not None: + data["records"] = [ZoneRecord.from_dict(o) for o in raw] + + super().__init__(client, data, complete) + + def _get_self(self) -> BoundZoneRRSet: + assert self.data_model.zone is not None + assert self.data_model.type is not None + return self._client.get_rrset( + self.data_model.zone, + self.data_model.name, + self.data_model.type, + ) + + def update_rrset( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + return self._client.update_rrset(self, labels=labels) + + def delete_rrset( + self, + ) -> DeleteZoneRRSetResponse: + """ + Deletes the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + """ + return self._client.delete_rrset(self) + + def change_rrset_protection( + self, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param change: Prevent the Zone from being changed (deletion and updates). + """ + return self._client.change_rrset_protection(self, change=change) + + def change_rrset_ttl( + self, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param change: Time To Live (TTL) of the RRSet. + """ + return self._client.change_rrset_ttl(self, ttl=ttl) + + def add_rrset_records( + self, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + """ + return self._client.add_rrset_records(self, records=records, ttl=ttl) + + def update_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset + + :param records: Records to update in the RRSet. + """ + return self._client.update_rrset_records(self, records=records) + + def remove_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param records: Records to remove from the RRSet. + """ + return self._client.remove_rrset_records(self, records=records) + + def set_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param records: Records to set in the RRSet. + """ + return self._client.set_rrset_records(self, records=records) + + +class ZonesPageResult(NamedTuple): + zones: list[BoundZone] + meta: Meta + + +class ZoneRRSetsPageResult(NamedTuple): + rrsets: list[BoundZoneRRSet] + meta: Meta + + +class ZonesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + """ + ZoneClient is a client for the Zone (DNS) API. + + See https://docs.hetzner.cloud/reference/cloud#zones and https://docs.hetzner.cloud/reference/cloud#zone-rrsets. + """ + + _base_url = "/zones" + + actions: ResourceActionsClient + """Zones scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get(self, id_or_name: int | str) -> BoundZone: + """ + Returns a single Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-get-a-zone + + :param id_or_name: ID or Name of the Zone. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id_or_name}", + ) + return BoundZone(self, response["zone"]) + + def get_list( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZonesPageResult: + """ + Returns a list of Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if mode is not None: + params["mode"] = mode + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return ZonesPageResult( + zones=[BoundZone(self, item) for item in response["zones"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZone]: + """ + Returns a list of all Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_list, + name=name, + mode=mode, + label_selector=label_selector, + sort=sort, + ) + + def create( + self, + *, + name: str, + mode: ZoneMode, + ttl: int | None = None, + labels: dict[str, str] | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + rrsets: list[ZoneRRSet] | None = None, + zonefile: str | None = None, + ) -> CreateZoneResponse: + """ + Creates a Zone. + + A default SOA and three NS resource records with the assigned Hetzner nameservers are created automatically. + + See https://docs.hetzner.cloud/reference/cloud#zones-create-a-zone + + :param name: Name of the Zone. + :param mode: Mode of the Zone. + :param ttl: Default Time To Live (TTL) of the Zone. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param primary_nameservers: Primary nameservers of the Zone. + :param rrsets: RRSets to be added to the Zone. + :param zonefile: Zone file to import. + """ + data: dict[str, Any] = { + "name": name, + "mode": mode, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if primary_nameservers is not None: + data["primary_nameservers"] = [o.to_payload() for o in primary_nameservers] + if rrsets is not None: + data["rrsets"] = [o.to_payload() for o in rrsets] + if zonefile is not None: + data["zonefile"] = zonefile + + response = self._client.request( + method="POST", + url=f"{self._base_url}", + json=data, + ) + + return CreateZoneResponse( + zone=BoundZone(self, response["zone"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update( + self, + zone: Zone | BoundZone, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param zone: Zone to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{zone.id_or_name}", + json=data, + ) + return BoundZone(self, response["zone"]) + + def delete( + self, + zone: Zone | BoundZone, + ) -> DeleteZoneResponse: + """ + Deletes a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + + :param zone: Zone to delete. + """ + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{zone.id_or_name}", + ) + + return DeleteZoneResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def export_zonefile( + self, + zone: Zone | BoundZone, + ) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + + :param zone: Zone to export the zone file from. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/zonefile", + ) + return ExportZonefileResponse(response["zonefile"]) + + def get_actions_list( + self, + zone: Zone | BoundZone, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns a paginated list of Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. + """ + return self._get_actions_list( + f"{self._base_url}/{zone.id_or_name}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + zone: Zone | BoundZone, + *, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + zone, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zone: Zone | BoundZone, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zone: Zone to import the zone file into. + :param zonefile: Zone file to import. + """ + data: dict[str, Any] = { + "zonefile": zonefile, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/import_zonefile", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_protection( + self, + zone: Zone | BoundZone, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param zone: Zone to update. + :param delete: Prevents the Zone from being deleted. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_ttl( + self, + zone: Zone | BoundZone, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param zone: Zone to update. + :param ttl: Default Time To Live (TTL) of the Zone. + """ + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_primary_nameservers( + self, + zone: Zone | BoundZone, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param zone: Zone to update. + :param primary_nameservers: Primary nameservers of the Zone. + """ + data: dict[str, Any] = { + "primary_nameservers": [o.to_payload() for o in primary_nameservers], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_primary_nameservers", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def get_rrset( + self, + zone: Zone | BoundZone, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param zone: Zone to fetch the RRSet from. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets/{name}/{type}", + ) + return BoundZoneRRSet(self, response["rrset"]) + + def get_rrset_list( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if type is not None: + params["type"] = type + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + params=params, + ) + return ZoneRRSetsPageResult( + rrsets=[BoundZoneRRSet(self, item) for item in response["rrsets"]], + meta=Meta.parse_meta(response), + ) + + def get_rrset_all( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + """ + return self._iter_pages( + self.get_rrset_list, + zone, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + zone: Zone | BoundZone, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param zone: Zone to create the RRSets in. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + """ + data: dict[str, Any] = { + "name": name, + "type": type, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if records is not None: + data["records"] = [o.to_payload() for o in records] + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + json=data, + ) + return CreateZoneRRSetResponse( + rrset=BoundZoneRRSet(self, response["rrset"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + json=data, + ) + return BoundZoneRRSet(self, response["rrset"]) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + ) + return DeleteZoneRRSetResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if change is not None: + data["change"] = change + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + if ttl is not None: + data["ttl"] = ttl + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def update_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Updates records in a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-update-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to update in the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/update_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/zones/domain.py b/hcloud/zones/domain.py new file mode 100644 index 00000000..77aee489 --- /dev/null +++ b/hcloud/zones/domain.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundZone, BoundZoneRRSet + + +__all__ = [ + "ZoneMode", + "ZoneStatus", + "ZoneRegistrar", + "Zone", + "ZonePrimaryNameserver", + "ZoneAuthoritativeNameservers", + "ZoneProtection", + "CreateZoneResponse", + "DeleteZoneResponse", + "ExportZonefileResponse", + "ZoneRRSet", + "ZoneRRSetProtection", + "ZoneRecord", + "CreateZoneRRSetResponse", + "DeleteZoneRRSetResponse", +] + +ZoneMode = Literal["primary", "secondary"] +ZoneStatus = Literal["ok", "updating", "error"] +ZoneRegistrar = Literal["hetzner", "other", "unknown"] + + +class Zone(BaseDomain, DomainIdentityMixin): + """ + Zone Domain. + + See https://docs.hetzner.cloud/reference/cloud#zones. + """ + + MODE_PRIMARY = "primary" + """ + Zone in primary mode, resource record sets (RRSets) and resource records (RRs) are + managed via the Cloud API or Cloud Console. + """ + MODE_SECONDARY = "secondary" + """ + Zone in secondary mode, Hetzner's nameservers query RRSets and RRs from given + primary nameservers via AXFR. + """ + + STATUS_OK = "ok" + """The Zone is pushed to the authoritative nameservers.""" + STATUS_UPDATING = "updating" + """The Zone is currently being published to the authoritative nameservers.""" + STATUS_ERROR = "error" + """The Zone could not be published to the authoritative nameservers.""" + + REGISTRAR_HETZNER = "hetzner" + REGISTRAR_OTHER = "other" + REGISTRAR_UNKNOWN = "unknown" + + __api_properties__ = ( + "id", + "name", + "created", + "mode", + "ttl", + "labels", + "protection", + "status", + "record_count", + "registrar", + "primary_nameservers", + "authoritative_nameservers", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + created: str | None = None, + mode: ZoneMode | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneProtection | None = None, + status: ZoneStatus | None = None, + record_count: int | None = None, + registrar: ZoneRegistrar | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + authoritative_nameservers: ZoneAuthoritativeNameservers | None = None, + ): + self.id = id + self.name = name + self.created = self._parse_datetime(created) + self.mode = mode + self.ttl = ttl + self.labels = labels + self.protection = protection + self.status = status + self.record_count = record_count + self.registrar = registrar + self.primary_nameservers = primary_nameservers + self.authoritative_nameservers = authoritative_nameservers + + +ZonePrimaryNameserverTSIGAlgorithm = Literal[ + "hmac-md5", + "hmac-sha1", + "hmac-sha256", +] + + +class ZonePrimaryNameserver(BaseDomain): + """ + Zone Primary Nameserver Domain. + """ + + TSIG_ALGORITHM_HMAC_MD5 = "hmac-md5" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA1 = "hmac-sha1" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA256 = "hmac-sha256" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + + __api_properties__ = ( + "address", + "port", + "tsig_algorithm", + "tsig_key", + ) + __slots__ = __api_properties__ + + def __init__( + self, + address: str, + port: int | None = None, + tsig_algorithm: ZonePrimaryNameserverTSIGAlgorithm | None = None, + tsig_key: str | None = None, + ): + self.address = address + self.port = port + self.tsig_algorithm = tsig_algorithm + self.tsig_key = tsig_key + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "address": self.address, + } + if self.port is not None: + payload["port"] = self.port + if self.tsig_algorithm is not None: + payload["tsig_algorithm"] = self.tsig_algorithm + if self.tsig_key is not None: + payload["tsig_key"] = self.tsig_key + + return payload + + +ZoneAuthoritativeNameserversDelegationStatus = Literal[ + "valid", + "partially-valid", + "invalid", + "lame", + "unregistered", + "unknown", +] + + +class ZoneAuthoritativeNameservers(BaseDomain): + """ + Zone Authoritative Nameservers Domain. + """ + + DELEGATION_STATUS_VALID = "valid" + DELEGATION_STATUS_PARTIALLY_VALID = "partially-valid" + DELEGATION_STATUS_INVALID = "invalid" + DELEGATION_STATUS_LAME = "lame" + DELEGATION_STATUS_UNREGISTERED = "unregistered" + DELEGATION_STATUS_UNKNOWN = "unknown" + + __api_properties__ = ( + "assigned", + "delegated", + "delegation_last_check", + "delegation_status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + assigned: list[str] | None = None, + delegated: list[str] | None = None, + delegation_last_check: str | None = None, + delegation_status: ZoneAuthoritativeNameserversDelegationStatus | None = None, + ): + self.assigned = assigned + self.delegated = delegated + self.delegation_last_check = self._parse_datetime(delegation_last_check) + self.delegation_status = delegation_status + + +class ZoneProtection(TypedDict): + """ + Zone Protection. + """ + + delete: bool + + +class CreateZoneResponse(BaseDomain): + """ + Create Zone Response Domain. + """ + + __api_properties__ = ("zone", "action") + __slots__ = __api_properties__ + + def __init__( + self, + zone: BoundZone, + action: BoundAction, + ): + self.zone = zone + self.action = action + + +class DeleteZoneResponse(BaseDomain): + """ + Delete Zone Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class ExportZonefileResponse(BaseDomain): + """ + Export Zonefile Response Domain. + """ + + __api_properties__ = ("zonefile",) + __slots__ = __api_properties__ + + def __init__( + self, + zonefile: str, + ): + self.zonefile = zonefile + + +ZoneRRSetType = Literal[ + "A", + "AAAA", + "CAA", + "CNAME", + "DS", + "HINFO", + "HTTPS", + "MX", + "NS", + "PTR", + "RP", + "SOA", + "SRV", + "SVCB", + "TLSA", + "TXT", +] + + +class ZoneRRSet(BaseDomain): + """ + Zone RRSet Domain. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets + """ + + TYPE_A = "A" + TYPE_AAAA = "AAAA" + TYPE_CAA = "CAA" + TYPE_CNAME = "CNAME" + TYPE_DS = "DS" + TYPE_HINFO = "HINFO" + TYPE_HTTPS = "HTTPS" + TYPE_MX = "MX" + TYPE_NS = "NS" + TYPE_PTR = "PTR" + TYPE_RP = "RP" + TYPE_SOA = "SOA" + TYPE_SRV = "SRV" + TYPE_SVCB = "SVCB" + TYPE_TLSA = "TLSA" + TYPE_TXT = "TXT" + + __api_properties__ = ( + "name", + "type", + "ttl", + "labels", + "protection", + "records", + "id", + "zone", + ) + __slots__ = __api_properties__ + + def __init__( + self, + name: str | None = None, + type: ZoneRRSetType | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneRRSetProtection | None = None, + records: list[ZoneRecord] | None = None, + id: str | None = None, + zone: BoundZone | Zone | None = None, + ): + # Ensure that 'id', 'name' and 'type' are always populated. + if name is not None and type is not None: + if id is None: + id = f"{name}/{type}" + else: + if id is not None: + name, _, type = id.partition("/") # type: ignore[assignment] + else: + raise ValueError("id or name and type must be set") + + self.name = name + self.type = type + self.ttl = ttl + self.labels = labels + self.protection = protection + self.records = records + + self.id = id + self.zone = zone + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "name": self.name, + "type": self.type, + } + if self.ttl is not None: + payload["ttl"] = self.ttl + if self.labels is not None: + payload["labels"] = self.labels + if self.protection is not None: + payload["protection"] = self.protection + if self.records is not None: + payload["records"] = [o.to_payload() for o in self.records] + + return payload + + +class ZoneRRSetProtection(TypedDict): + """ + Zone RRSet Protection. + """ + + change: bool + + +class ZoneRecord(BaseDomain): + """ + Zone Record Domain. + """ + + __api_properties__ = ( + "value", + "comment", + ) + __slots__ = __api_properties__ + + def __init__( + self, + value: str, + comment: str | None = None, + ): + self.value = value + self.comment = comment + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "value": self.value, + } + if self.comment is not None: + payload["comment"] = self.comment + + return payload + + +class CreateZoneRRSetResponse(BaseDomain): + """ + Create Zone RRSet Response Domain. + """ + + __api_properties__ = ( + "rrset", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + rrset: BoundZoneRRSet, + action: BoundAction, + ): + self.rrset = rrset + self.action = action + + +class DeleteZoneRRSetResponse(BaseDomain): + """ + Delete Zone RRSet Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/pyproject.toml b/pyproject.toml index 60a7dd06..cbf37daf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,9 @@ combine_as_imports = true add_imports = ["from __future__ import annotations"] [tool.mypy] +strict = true disallow_untyped_defs = true +implicit_reexport = false [tool.coverage.run] source = ["hcloud"] @@ -14,7 +16,7 @@ requires = ["setuptools"] build-backend = "setuptools.build_meta" [tool.pylint.main] -py-version = "3.8" +py-version = "3.10" recursive = true jobs = 0 @@ -35,4 +37,5 @@ disable = [ "too-many-arguments", "too-many-instance-attributes", "too-many-lines", + "too-many-positional-arguments", ] diff --git a/setup.py b/setup.py index b1002009..93f2a6ab 100644 --- a/setup.py +++ b/setup.py @@ -5,13 +5,9 @@ with open("README.md", encoding="utf-8") as readme_file: readme = readme_file.read() -version = {} -with open("hcloud/__version__.py", encoding="utf-8") as fp: - exec(fp.read(), version) - setup( name="hcloud", - version=version["VERSION"], + version="2.23.0", # x-releaser-pleaser-version keywords="hcloud hetzner cloud", description="Official Hetzner Cloud python library", long_description=readme, @@ -25,37 +21,36 @@ "Changelog": "https://github.com/hetznercloud/hcloud-python/blob/main/CHANGELOG.md", "Source Code": "https://github.com/hetznercloud/hcloud-python", }, - license="MIT license", + license="MIT", classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], - python_requires=">=3.8", + python_requires=">=3.10", install_requires=[ "python-dateutil>=2.7.5", "requests>=2.20", ], extras_require={ "docs": [ - "sphinx>=7.2.2,<7.3", - "sphinx-rtd-theme>=2,<2.1", - "myst-parser>=2.0.0,<2.1", - "watchdog>=4,<4.1", + "sphinx>=9,<9.2", + "sphinx-rtd-theme>=3,<3.2", + "myst-parser>=5,<5.2", + "watchdog>=6,<6.1", ], "test": [ - "coverage>=7.4,<7.5", - "pylint>=3,<3.2", - "pytest>=8,<8.2", - "pytest-cov>=5,<5.1", - "mypy>=1.9,<1.10", + "coverage>=7.15,<7.16", + "pylint>=4,<4.1", + "pytest>=9,<9.2", + "pytest-cov>=7,<7.2", + "mypy>=2.3,<2.4", "types-python-dateutil", "types-requests", ], diff --git a/tests/unit/actions/conftest.py b/tests/unit/actions/conftest.py deleted file mode 100644 index dbc3cc9f..00000000 --- a/tests/unit/actions/conftest.py +++ /dev/null @@ -1,79 +0,0 @@ -from __future__ import annotations - -import pytest - - -@pytest.fixture() -def generic_action_list(): - return { - "actions": [ - { - "id": 1, - "command": "start_server", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - }, - { - "id": 2, - "command": "stop_server", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - }, - ] - } - - -@pytest.fixture() -def running_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "status": "running", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - } - - -@pytest.fixture() -def successfully_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "status": "success", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - } - - -@pytest.fixture() -def failed_action(): - return { - "action": { - "id": 2, - "command": "stop_server", - "status": "error", - "progress": 100, - "started": "2016-01-30T23:55:00+00:00", - "finished": "2016-01-30T23:56:00+00:00", - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } - } diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index 519a8b88..26930de4 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -1,198 +1,479 @@ from __future__ import annotations +import inspect +import warnings from unittest import mock import pytest +from hcloud import Client from hcloud.actions import ( - Action, ActionFailedException, ActionsClient, ActionTimeoutException, BoundAction, ResourceActionsClient, ) +from hcloud.certificates import BoundCertificate, CertificatesClient +from hcloud.core import BoundModelBase, ResourceClientBase +from hcloud.firewalls import BoundFirewall, FirewallsClient +from hcloud.floating_ips import BoundFloatingIP, FloatingIPsClient +from hcloud.images import BoundImage, ImagesClient +from hcloud.load_balancers import BoundLoadBalancer, LoadBalancersClient +from hcloud.networks import BoundNetwork, NetworksClient +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient +from hcloud.servers import BoundServer, ServersClient +from hcloud.storage_boxes import BoundStorageBox, StorageBoxesClient +from hcloud.volumes import BoundVolume, VolumesClient +from hcloud.zones import BoundZone, ZonesClient + +from ..conftest import assert_bound_action1, assert_bound_action2 + +resources_with_actions: dict[str, tuple[ResourceClientBase, BoundModelBase]] = { + "certificates": (CertificatesClient, BoundCertificate), + "firewalls": (FirewallsClient, BoundFirewall), + "floating_ips": (FloatingIPsClient, BoundFloatingIP), + "images": (ImagesClient, BoundImage), + "load_balancers": (LoadBalancersClient, BoundLoadBalancer), + "networks": (NetworksClient, BoundNetwork), + "primary_ips": (PrimaryIPsClient, BoundPrimaryIP), + "servers": (ServersClient, BoundServer), + "volumes": (VolumesClient, BoundVolume), + "zones": (ZonesClient, BoundZone), + "storage_boxes": (StorageBoxesClient, BoundStorageBox), +} + + +def test_resources_with_actions(client: Client): + """ + Ensure that the list of resource clients above is up to date. + """ + 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 + + resource_client_class, _ = resources_with_actions[name] + assert member.__class__ is resource_client_class + + assert len(members) == len(resources_with_actions) class TestBoundAction: @pytest.fixture() - def bound_running_action(self, mocked_requests): - action_client = ActionsClient(client=mocked_requests) - # Speed up tests that run `wait_until_finished` - action_client._client.poll_interval = 0.1 - - return BoundAction( - client=action_client, - data=dict(id=14, status=Action.STATUS_RUNNING), - ) + def bound_running_action(self, client: Client, action1_running): + return BoundAction(client=client.actions, data=action1_running) def test_wait_until_finished( - self, bound_running_action, mocked_requests, running_action, successfully_action + self, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_success, ): - mocked_requests.request.side_effect = [running_action, successfully_action] + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_success}, + ] + bound_running_action.wait_until_finished() - mocked_requests.request.assert_called_with(url="/actions/2", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/actions/1", + ) assert bound_running_action.status == "success" - assert mocked_requests.request.call_count == 2 + assert bound_running_action.id == 1 + + assert request_mock.call_count == 2 def test_wait_until_finished_with_error( - self, bound_running_action, mocked_requests, running_action, failed_action + self, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_error, ): - mocked_requests.request.side_effect = [running_action, failed_action] - with pytest.raises(ActionFailedException) as exception_info: + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_error}, + ] + + with pytest.raises(ActionFailedException) as exc: bound_running_action.wait_until_finished() assert bound_running_action.status == "error" - assert exception_info.value.action.id == 2 + assert bound_running_action.id == 1 + assert exc.value.action.id == 1 + + assert request_mock.call_count == 2 def test_wait_until_finished_max_retries( - self, bound_running_action, mocked_requests, running_action, successfully_action + self, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_success, ): - mocked_requests.request.side_effect = [ - running_action, - running_action, - successfully_action, + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_running}, + {"action": action1_success}, ] - with pytest.raises(ActionTimeoutException) as exception_info: + + with pytest.raises(ActionTimeoutException) as exc: bound_running_action.wait_until_finished(max_retries=1) assert bound_running_action.status == "running" - assert exception_info.value.action.id == 2 - assert mocked_requests.request.call_count == 1 + assert bound_running_action.id == 1 + assert exc.value.action.id == 1 + + assert request_mock.call_count == 1 class TestResourceActionsClient: + """ + //actions + //actions/ + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request) -> str: + return request.param + @pytest.fixture() - def actions_client(self): - return ResourceActionsClient(client=mock.MagicMock(), resource="/resource") + def resource_client(self, client: Client, resource: str) -> ResourceActionsClient: + """ + Extract the resource actions client from the client. + """ + return getattr(client, resource).actions + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: ResourceActionsClient, + resource: str, + action_response, + ): + request_mock.return_value = action_response - def test_get_by_id(self, actions_client, generic_action): - actions_client._client.request.return_value = generic_action - action = actions_client.get_by_id(1) - actions_client._client.request.assert_called_with( - url="/resource/actions/1", method="GET" + action = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/actions/1", ) - assert action._client == actions_client._client.actions - assert action.id == 1 - assert action.command == "stop_server" + + assert_bound_action1(action, resource_client._parent.actions) @pytest.mark.parametrize( "params", - [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) - def test_get_list(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list - result = actions_client.get_list(**params) - actions_client._client.request.assert_called_with( - url="/resource/actions", method="GET", params=params + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: ResourceActionsClient, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/actions", + params=params, ) - assert result.meta is None + assert result.meta is not None actions = result.actions assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - action1 = actions[0] - action2 = actions[1] + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: ResourceActionsClient, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = resource_client.get_all(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - assert action1._client == actions_client._client.actions - assert action1.id == 1 - assert action1.command == "start_server" - assert action2._client == actions_client._client.actions - assert action2.id == 2 - assert action2.command == "stop_server" +class TestResourceObjectActionsClient: + """ + ///actions + """ - @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) - def test_get_all(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list - actions = actions_client.get_all(**params) + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + return request.param - params.update({"page": 1, "per_page": 50}) + @pytest.fixture() + def resource_client(self, client: Client, resource: str) -> ResourceClientBase: + return getattr(client, resource) + + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> BoundModelBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) - actions_client._client.request.assert_called_with( - url="/resource/actions", method="GET", params=params + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + resource_client: ResourceClientBase, + resource: str, + bound_model: BoundModelBase, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = resource_client.get_actions_list(bound_model, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, ) + assert result.meta is not None + + actions = result.actions assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - action1 = actions[0] - action2 = actions[1] + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + resource_client: ResourceClientBase, + resource: str, + bound_model: BoundModelBase, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = resource_client.get_actions(bound_model, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) + + +class TestBoundModelActions: + """ + ///actions + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + return request.param + + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> ResourceClientBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = bound_model.get_actions_list(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, + ) + + assert result.meta is not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) - assert action1._client == actions_client._client.actions - assert action1.id == 1 - assert action1.command == "start_server" + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = bound_model.get_actions(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) - assert action2._client == actions_client._client.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) class TestActionsClient: @pytest.fixture() - def actions_client(self): - return ActionsClient(client=mock.MagicMock()) + def actions_client(self, client: Client) -> ActionsClient: + return client.actions + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + action_response, + ): + request_mock.return_value = action_response - def test_get_by_id(self, actions_client, generic_action): - actions_client._client.request.return_value = generic_action action = actions_client.get_by_id(1) - actions_client._client.request.assert_called_with( - url="/actions/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/actions/1", ) - assert action._client == actions_client._client.actions - assert action.id == 1 - assert action.command == "stop_server" + assert_bound_action1(action, actions_client) @pytest.mark.parametrize( "params", - [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) - def test_get_list(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list + def test_get_list( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + with pytest.deprecated_call(): result = actions_client.get_list(**params) - actions_client._client.request.assert_called_with( - url="/actions", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/actions", + params=params, ) - assert result.meta is None + assert result.meta is not None actions = result.actions assert len(actions) == 2 + assert_bound_action1(actions[0], actions_client) + assert_bound_action2(actions[1], actions_client) - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._client.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._client.actions - assert action2.id == 2 - assert action2.command == "stop_server" + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + action_list_response, + params, + ): + request_mock.return_value = action_list_response - @pytest.mark.parametrize("params", [{}, {"status": ["active"], "sort": ["status"]}]) - def test_get_all(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list with pytest.deprecated_call(): actions = actions_client.get_all(**params) - params.update({"page": 1, "per_page": 50}) - - actions_client._client.request.assert_called_with( - url="/actions", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/actions", + params={**params, "page": 1, "per_page": 50}, ) assert len(actions) == 2 - - action1 = actions[0] - action2 = actions[1] - - assert action1._client == actions_client._client.actions - assert action1.id == 1 - assert action1.command == "start_server" - - assert action2._client == actions_client._client.actions - assert action2.id == 2 - assert action2.command == "stop_server" + assert_bound_action1(actions[0], actions_client) + assert_bound_action2(actions[1], actions_client) diff --git a/tests/unit/actions/test_domain.py b/tests/unit/actions/test_domain.py index 5133a240..395af182 100644 --- a/tests/unit/actions/test_domain.py +++ b/tests/unit/actions/test_domain.py @@ -13,6 +13,16 @@ ) +@pytest.mark.parametrize( + "value", + [ + (Action(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestAction: def test_started_finished_is_datetime(self): action = Action( diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index 0857d0c9..8182bb48 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -4,7 +4,7 @@ import pytest -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.certificates import ( BoundCertificate, Certificate, @@ -12,114 +12,62 @@ ManagedCertificateStatus, ) +from ..conftest import BoundModelTestCase -class TestBoundCertificate: - @pytest.fixture() - def bound_certificate(self, hetzner_client): - return BoundCertificate(client=hetzner_client.certificates, data=dict(id=14)) - - @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) - def test_get_actions_list( - self, hetzner_client, bound_certificate, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - result = bound_certificate.get_actions_list(**params) - hetzner_client.request.assert_called_with( - url="/certificates/14/actions", method="GET", params=params - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_get_actions(self, hetzner_client, bound_certificate, response_get_actions): - hetzner_client.request.return_value = response_get_actions - actions = bound_certificate.get_actions() - - params = {"page": 1, "per_page": 50} - - hetzner_client.request.assert_called_with( - url="/certificates/14/actions", method="GET", params=params - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - def test_bound_certificate_init(self, certificate_response): - bound_certificate = BoundCertificate( - client=mock.MagicMock(), data=certificate_response["certificate"] - ) - - assert bound_certificate.id == 2323 - assert bound_certificate.name == "My Certificate" - assert bound_certificate.type == "managed" - assert ( - bound_certificate.fingerprint - == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" - ) - assert bound_certificate.certificate == "-----BEGIN CERTIFICATE-----\n..." - assert len(bound_certificate.domain_names) == 3 - assert bound_certificate.domain_names[0] == "example.com" - assert bound_certificate.domain_names[1] == "webmail.example.com" - assert bound_certificate.domain_names[2] == "www.example.com" - assert isinstance(bound_certificate.status, ManagedCertificateStatus) - assert bound_certificate.status.issuance == "failed" - assert bound_certificate.status.renewal == "scheduled" - assert bound_certificate.status.error.code == "error_code" - assert bound_certificate.status.error.message == "error message" - - def test_update( - self, hetzner_client, bound_certificate, response_update_certificate - ): - hetzner_client.request.return_value = response_update_certificate - certificate = bound_certificate.update(name="New name") - hetzner_client.request.assert_called_with( - url="/certificates/14", method="PUT", json={"name": "New name"} - ) - - assert certificate.id == 2323 - assert certificate.name == "New name" +class TestBoundCertificate(BoundModelTestCase): + methods = [ + BoundCertificate.update, + BoundCertificate.delete, + BoundCertificate.retry_issuance, + ] - def test_delete(self, hetzner_client, bound_certificate, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_certificate.delete() - hetzner_client.request.assert_called_with( - url="/certificates/14", method="DELETE" - ) - - assert delete_success is True + @pytest.fixture() + def resource_client(self, client: Client): + return client.certificates - def test_retry_issuance( - self, hetzner_client, bound_certificate, response_retry_issuance_action - ): - hetzner_client.request.return_value = response_retry_issuance_action - action = bound_certificate.retry_issuance() - hetzner_client.request.assert_called_with( - url="/certificates/14/actions/retry", method="POST" + @pytest.fixture() + def bound_model(self, resource_client, certificate_response): + return BoundCertificate( + resource_client, data=certificate_response["certificate"] ) - assert action.id == 14 - assert action.command == "issue_certificate" + def test_init(self, bound_model: BoundCertificate): + o = bound_model + assert o.id == 2323 + assert o.name == "My Certificate" + assert o.type == "managed" + assert o.fingerprint == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + assert o.certificate == "-----BEGIN CERTIFICATE-----\n..." + assert len(o.domain_names) == 3 + assert o.domain_names[0] == "example.com" + assert o.domain_names[1] == "webmail.example.com" + assert o.domain_names[2] == "www.example.com" + assert isinstance(o.status, ManagedCertificateStatus) + assert o.status.issuance == "failed" + assert o.status.renewal == "scheduled" + assert o.status.error.code == "error_code" + assert o.status.error.message == "error message" class TestCertificatesClient: @pytest.fixture() - def certificates_client(self): - return CertificatesClient(client=mock.MagicMock()) + def certificates_client(self, client: Client): + return CertificatesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate_response, + ): + request_mock.return_value = certificate_response - def test_get_by_id(self, certificates_client, certificate_response): - certificates_client._client.request.return_value = certificate_response certificate = certificates_client.get_by_id(1) - certificates_client._client.request.assert_called_with( - url="/certificates/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/certificates/1", ) assert certificate._client is certificates_client assert certificate.id == 2323 @@ -138,11 +86,21 @@ def test_get_by_id(self, certificates_client, certificate_response): {}, ], ) - def test_get_list(self, certificates_client, two_certificates_response, params): - certificates_client._client.request.return_value = two_certificates_response + def test_get_list( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + two_certificates_response, + params, + ): + request_mock.return_value = two_certificates_response + result = certificates_client.get_list(**params) - certificates_client._client.request.assert_called_with( - url="/certificates", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/certificates", + params=params, ) certificates = result.certificates @@ -162,13 +120,23 @@ def test_get_list(self, certificates_client, two_certificates_response, params): @pytest.mark.parametrize( "params", [{"name": "My Certificate", "label_selector": "label1"}, {}] ) - def test_get_all(self, certificates_client, two_certificates_response, params): - certificates_client._client.request.return_value = two_certificates_response + def test_get_all( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + two_certificates_response, + params, + ): + request_mock.return_value = two_certificates_response + certificates = certificates_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - certificates_client._client.request.assert_called_with( - url="/certificates", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/certificates", + params=params, ) assert len(certificates) == 2 @@ -184,29 +152,45 @@ def test_get_all(self, certificates_client, two_certificates_response, params): assert certificates2.id == 2324 assert certificates2.name == "My website cert" - def test_get_by_name(self, certificates_client, one_certificates_response): - certificates_client._client.request.return_value = one_certificates_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + one_certificates_response, + ): + request_mock.return_value = one_certificates_response + certificates = certificates_client.get_by_name("My Certificate") params = {"name": "My Certificate"} - certificates_client._client.request.assert_called_with( - url="/certificates", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/certificates", + params=params, ) assert certificates._client is certificates_client assert certificates.id == 2323 assert certificates.name == "My Certificate" - def test_create(self, certificates_client, certificate_response): - certificates_client._client.request.return_value = certificate_response + def test_create( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate_response, + ): + request_mock.return_value = certificate_response + certificate = certificates_client.create( name="My Certificate", certificate="-----BEGIN CERTIFICATE-----\n...", private_key="-----BEGIN PRIVATE KEY-----\n...", ) - certificates_client._client.request.assert_called_with( - url="/certificates", + + request_mock.assert_called_with( method="POST", + url="/certificates", json={ "name": "My Certificate", "certificate": "-----BEGIN CERTIFICATE-----\n...", @@ -219,17 +203,20 @@ def test_create(self, certificates_client, certificate_response): assert certificate.name == "My Certificate" def test_create_managed( - self, certificates_client, create_managed_certificate_response + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + create_managed_certificate_response, ): - certificates_client._client.request.return_value = ( - create_managed_certificate_response - ) + request_mock.return_value = create_managed_certificate_response + create_managed_certificate_rsp = certificates_client.create_managed( name="My Certificate", domain_names=["example.com", "*.example.org"] ) - certificates_client._client.request.assert_called_with( - url="/certificates", + + request_mock.assert_called_with( method="POST", + url="/certificates", json={ "name": "My Certificate", "domain_names": ["example.com", "*.example.org"], @@ -247,12 +234,20 @@ def test_create_managed( [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) def test_update( - self, certificates_client, certificate, response_update_certificate + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_update_certificate, ): - certificates_client._client.request.return_value = response_update_certificate + request_mock.return_value = response_update_certificate + certificate = certificates_client.update(certificate, name="New name") - certificates_client._client.request.assert_called_with( - url="/certificates/1", method="PUT", json={"name": "New name"} + + request_mock.assert_called_with( + method="PUT", + url="/certificates/1", + json={"name": "New name"}, ) assert certificate.id == 2323 @@ -262,11 +257,20 @@ def test_update( "certificate", [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) - def test_delete(self, certificates_client, certificate, generic_action): - certificates_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + action_response, + ): + request_mock.return_value = action_response + delete_success = certificates_client.delete(certificate) - certificates_client._client.request.assert_called_with( - url="/certificates/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/certificates/1", ) assert delete_success is True @@ -276,65 +280,20 @@ def test_delete(self, certificates_client, certificate, generic_action): [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], ) def test_retry_issuance( - self, certificates_client, certificate, response_retry_issuance_action + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_retry_issuance_action, ): - certificates_client._client.request.return_value = ( - response_retry_issuance_action - ) + request_mock.return_value = response_retry_issuance_action + action = certificates_client.retry_issuance(certificate) - certificates_client._client.request.assert_called_with( - url="/certificates/1/actions/retry", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/certificates/1/actions/retry", ) assert action.id == 14 assert action.command == "issue_certificate" - - def test_actions_get_by_id(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = certificates_client.actions.get_by_id(13) - - certificates_client._client.request.assert_called_with( - url="/certificates/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == certificates_client._client.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = response_get_actions - result = certificates_client.actions.get_list() - - certificates_client._client.request.assert_called_with( - url="/certificates/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_actions_get_all(self, certificates_client, response_get_actions): - certificates_client._client.request.return_value = response_get_actions - actions = certificates_client.actions.get_all() - - certificates_client._client.request.assert_called_with( - url="/certificates/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == certificates_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" diff --git a/tests/unit/certificates/test_domain.py b/tests/unit/certificates/test_domain.py index df916964..3c3b2517 100644 --- a/tests/unit/certificates/test_domain.py +++ b/tests/unit/certificates/test_domain.py @@ -3,7 +3,25 @@ import datetime from datetime import timezone -from hcloud.certificates import Certificate +import pytest + +from hcloud.certificates import ( + Certificate, + ManagedCertificateError, + ManagedCertificateStatus, +) + + +@pytest.mark.parametrize( + "value", + [ + (Certificate(id=1),), + (ManagedCertificateError()), + (ManagedCertificateStatus()), + ], +) +def test_eq(value): + assert value.__eq__(value) class TestCertificate: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 0dd4df46..65446f4a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,40 +1,252 @@ +# pylint: disable=redefined-outer-name + from __future__ import annotations +import inspect +import warnings +from collections.abc import Callable +from typing import ClassVar, TypedDict from unittest import mock import pytest from hcloud import Client +from hcloud.actions import ActionsClient, BoundAction + + +@pytest.fixture(autouse=True, scope="session") +def patch_package_version(): + with mock.patch("hcloud._client.__version__", "0.0.0"): + yield + + +@pytest.fixture() +def request_mock() -> mock.MagicMock: + return mock.MagicMock() + + +@pytest.fixture() +def client(request_mock) -> Client: + c = Client( + token="TOKEN", + # Speed up tests that use `_poll_interval_func` + poll_interval=0.0, + poll_max_retries=3, + ) + c._client.request = request_mock + c._client_hetzner.request = request_mock + return c + + +def assert_bound_action1(o: BoundAction, client: ActionsClient): + assert o.id == 1 + assert o.command == "command" + assert o._client == client + + +def assert_bound_action2(o: BoundAction, client: ActionsClient): + assert o.id == 2 + assert o.command == "command" + assert o._client == client + + +@pytest.fixture() +def action1_running(): + return { + "id": 1, + "command": "command", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [{"id": 42, "type": "resource"}], + "error": None, + } + + +@pytest.fixture() +def action2_running(): + return { + "id": 2, + "command": "command", + "status": "running", + "progress": 20, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [{"id": 43, "type": "resource"}], + "error": None, + } + + +@pytest.fixture() +def action1_success(action1_running): + return { + **action1_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + } + + +@pytest.fixture() +def action2_success(action2_running): + return { + **action2_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + } + + +@pytest.fixture() +def action1_error(action1_running): + return { + **action1_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } -@pytest.fixture(autouse=True, scope="function") -def mocked_requests(): - patcher = mock.patch("hcloud._client.requests") - mocked_requests = patcher.start() - yield mocked_requests - patcher.stop() +@pytest.fixture() +def action2_error(action2_running): + return { + **action2_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } @pytest.fixture() -def generic_action(): +def action_response(action1_running): return { - "action": { - "id": 1, - "command": "stop_server", - "status": "running", - "progress": 0, - "started": "2016-01-30T23:50+00:00", - "finished": None, - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } + "action": action1_running, } @pytest.fixture() -def hetzner_client(): - client = Client(token="token") - patcher = mock.patch.object(client, "request") - patcher.start() - yield client - patcher.stop() +def action_list_response(action1_running, action2_running): + return { + "actions": [ + action1_running, + action2_running, + ], + } + + +def build_kwargs_mock(func: Callable) -> dict[str, mock.Mock]: + """ + Generate a kwargs dict that may be passed to the provided function for testing purposes. + """ + s = inspect.signature(func) + + kwargs = {} + for name, param in s.parameters.items(): + if name in ("self",): + continue + + if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY): + kwargs[name] = mock.Mock() + continue + + # Ignore **kwargs + if param.kind in (param.VAR_KEYWORD,): + continue + + raise NotImplementedError(f"unsupported parameter kind: {param.kind}") + + return kwargs + + +def pytest_generate_tests(metafunc: pytest.Metafunc): + """ + Magic function to generate a test for each bound model method. + """ + if "bound_model_method" in metafunc.fixturenames: + metafunc.parametrize("bound_model_method", metafunc.cls.methods) + + +class BoundModelTestOptions(TypedDict): + sub_resource: bool + client_method: str + + +class BoundModelTestCase: + methods: ClassVar[list[Callable | tuple[Callable, BoundModelTestOptions]]] + + def test_method_list(self, bound_model): + """ + Ensure the list of bound model methods is up to date. + """ + # Unpack methods + methods = [m[0] if isinstance(m, tuple) else m for m in self.__class__.methods] + + members_count = 0 + members_missing = [] + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + + members = inspect.getmembers( + bound_model, + lambda m: inspect.ismethod(m) + and m.__func__ in bound_model.__class__.__dict__.values(), + ) + + for name, member in members: + # Ignore private methods + if name.startswith("_"): + continue + + # Actions methods are already tested in TestBoundModelActions. + if name in ("__init__", "get_actions", "get_actions_list"): + continue + + if member.__func__ in methods: + members_count += 1 + else: + members_missing.append(member.__func__.__qualname__) + + assert not members_missing, "untested methods:\n" + ",\n".join(members_missing) + assert members_count == len(self.__class__.methods) + + def test_method( + self, + resource_client, + bound_model, + bound_model_method: Callable | tuple[Callable, BoundModelTestOptions], + ): + options = BoundModelTestOptions() + if isinstance(bound_model_method, tuple): + bound_model_method, options = bound_model_method + + resource_client_method_name = options.get( + "client_method", + bound_model_method.__name__, + ) + + # Check if the resource client has a method named after the bound model method. + assert hasattr(resource_client, resource_client_method_name) + + # Mock the resource client method. + resource_client_method_mock = mock.MagicMock() + setattr( + resource_client, + resource_client_method_name, + resource_client_method_mock, + ) + + kwargs = build_kwargs_mock(bound_model_method) + + # Call the bound model method + result = getattr(bound_model, bound_model_method.__name__)(**kwargs) + + if options.get("sub_resource"): + resource_client_method_mock.assert_called_with(**kwargs) + else: + resource_client_method_mock.assert_called_with(bound_model, **kwargs) + + assert result is resource_client_method_mock.return_value diff --git a/tests/unit/core/test_client.py b/tests/unit/core/test_client.py index 584af8cc..6e9561fd 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -6,14 +6,15 @@ import pytest from hcloud.actions import ActionsPageResult -from hcloud.core import BaseDomain, BoundModelBase, ClientEntityBase, Meta +from hcloud.core import BaseDomain, BoundModelBase, Meta, ResourceClientBase class TestBoundModelBase: @pytest.fixture() def bound_model_class(self): class Model(BaseDomain): - __slots__ = ("id", "name", "description") + __api_properties__ = ("id", "name", "description") + __slots__ = __api_properties__ def __init__(self, id, name="", description=""): self.id = id @@ -46,7 +47,7 @@ def test_get_non_exists_model_attribute_complete_model( client=client, data={"id": 1, "name": "name", "description": "description"} ) with pytest.raises(AttributeError): - bound_model.content + _ = bound_model.content client.get_by_id.assert_not_called() def test_get_exists_model_attribute_incomplete_model( @@ -76,12 +77,29 @@ def test_get_non_exists_model_attribute_incomplete_model( ): bound_model = bound_model_class(client=client, data={"id": 1}, complete=False) with pytest.raises(AttributeError): - bound_model.content + _ = bound_model.content client.get_by_id.assert_not_called() assert bound_model.complete is False + def test_equality(self, bound_model_class, client): + data = {"id": 1, "name": "name", "description": "my_description"} + bound_model_a = bound_model_class(client=client, data=data) + bound_model_b = bound_model_class(client=client, data=data) -class TestClientEntityBase: + # Comparing a bound model with a base domain + assert bound_model_a == bound_model_a.data_model + + # Identical bound models + assert bound_model_a == bound_model_b + assert bound_model_a == bound_model_b.data_model + + # Differing bound models + bound_model_b.data_model.name = "changed_name" + assert bound_model_a != bound_model_b + assert bound_model_a != bound_model_b.data_model + + +class TestResourceClientBase: @pytest.fixture() def client_class_constructor(self): def constructor(json_content_function): @@ -89,7 +107,7 @@ class CandiesPageResult(NamedTuple): candies: list[Any] meta: Meta - class CandiesClient(ClientEntityBase): + class CandiesClient(ResourceClientBase): def get_list(self, status=None, page=None, per_page=None): json_content = json_content_function(page) results = [ @@ -104,7 +122,7 @@ def get_list(self, status=None, page=None, per_page=None): @pytest.fixture() def client_class_with_actions_constructor(self): def constructor(json_content_function): - class CandiesClient(ClientEntityBase): + class CandiesClient(ResourceClientBase): def get_actions_list(self, status, page=None, per_page=None): json_content = json_content_function(page) results = [ @@ -119,7 +137,7 @@ def get_actions_list(self, status, page=None, per_page=None): def test_iter_pages_no_meta(self, client_class_constructor): json_content = {"candies": [1, 2]} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) @@ -134,7 +152,7 @@ def test_iter_pages_no_next_page(self, client_class_constructor): "meta": {"pagination": {"page": 1, "per_page": 11, "next_page": None}}, } - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) @@ -200,23 +218,23 @@ def json_content_function(p): def test_get_first_by_result_exists(self, client_class_constructor): json_content = {"candies": [1]} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) - result = candies_client._get_first_by(status="sweet") + result = candies_client._get_first_by(candies_client.get_list, status="sweet") assert result == (1, None, "sweet", None) def test_get_first_by_result_does_not_exist(self, client_class_constructor): json_content = {"candies": []} - def json_content_function(p): + def json_content_function(_): return json_content candies_client = client_class_constructor(json_content_function) - result = candies_client._get_first_by(status="sweet") + result = candies_client._get_first_by(candies_client.get_list, status="sweet") assert result is None diff --git a/tests/unit/core/test_domain.py b/tests/unit/core/test_domain.py index fabf7629..280dc3cb 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -10,7 +10,7 @@ class TestMeta: @pytest.mark.parametrize("json_content", [None, "", {}]) def test_parse_meta_empty_json(self, json_content): result = Meta.parse_meta(json_content) - assert result is None + assert result is not None def test_parse_meta_json_no_paginaton(self): json_content = {"meta": {}} @@ -42,7 +42,8 @@ def test_parse_meta_json_ok(self): class SomeDomain(BaseDomain, DomainIdentityMixin): - __slots__ = ("id", "name") + __api_properties__ = ("id", "name") + __slots__ = __api_properties__ def __init__(self, id=None, name=None): self.id = id @@ -70,32 +71,40 @@ def test_id_or_name_exception(self): assert str(error) == "id or name must be set" @pytest.mark.parametrize( - "other, expected", + "domain, id_or_name, expected", [ - (SomeDomain(id=1), True), - (SomeDomain(name="name1"), True), - (SomeDomain(id=1, name="name1"), True), - (SomeDomain(id=2), False), - (SomeDomain(name="name2"), False), - (SomeDomain(id=2, name="name2"), False), + (SomeDomain(id=1, name="name1"), 1, True), + (SomeDomain(id=1, name="name1"), "1", True), + (SomeDomain(id=1, name="name1"), "name1", True), + (SomeDomain(id=1, name="name1"), 2, False), + (SomeDomain(id=1, name="name1"), "2", False), + (SomeDomain(id=1, name="name1"), "name2", False), + (SomeDomain(id=1, name="3"), 3, True), + (SomeDomain(id=3, name="1"), "3", True), ], ) - def test_has_id_or_name_exception(self, other, expected): - domain = SomeDomain(id=1, name="name1") - assert domain.has_id_or_name(other.id_or_name) == expected + def test_has_id_or_name( + self, + domain: SomeDomain, + id_or_name: str | int, + expected: bool, + ): + assert domain.has_id_or_name(id_or_name) == expected class ActionDomain(BaseDomain, DomainIdentityMixin): - __slots__ = ("id", "name", "started") + __api_properties__ = ("id", "name", "started") + __slots__ = __api_properties__ def __init__(self, id, name="name1", started=None): self.id = id self.name = name - self.started = isoparse(started) if started else None + self.started = self._parse_datetime(started) class SomeOtherDomain(BaseDomain): - __slots__ = ("id", "name", "child") + __api_properties__ = ("id", "name", "child") + __slots__ = __api_properties__ def __init__(self, id=None, name=None, child=None): self.id = id @@ -153,3 +162,38 @@ def test_from_dict_ok(self, data_dict, expected_result): ) def test_repr_ok(self, data, expected): assert data.__repr__() == expected + + def test__eq__(self): + a1 = ActionDomain(id=1, name="action") + assert a1 == ActionDomain(id=1, name="action") + assert a1 != ActionDomain(id=2, name="action") + assert a1 != ActionDomain(id=1, name="something") + assert a1 != SomeOtherDomain(id=1, name="action") + + def test_nested__eq__(self): + child1 = ActionDomain(id=1, name="child") + d1 = SomeOtherDomain(id=1, name="parent", child=child1) + d2 = SomeOtherDomain(id=1, name="parent", child=child1) + + assert d1 == d2 + + d2.child = ActionDomain(id=2, name="child2") + + assert d1 != d2 + + def test_nested_list__eq__(self): + child1 = ActionDomain(id=1, name="child") + d1 = SomeOtherDomain(id=1, name="parent", child=[child1]) + d2 = SomeOtherDomain(id=1, name="parent", child=[child1]) + + assert d1 == d2 + + d2.child = [ActionDomain(id=2, name="child2")] + + assert d1 != d2 + + +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 369566b4..59d0d25b 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -1,12 +1,16 @@ from __future__ import annotations +import warnings from unittest import mock # noqa: F401 import pytest # noqa: F401 +from hcloud import Client 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): @@ -24,48 +28,62 @@ 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): - return DatacentersClient(client=mock.MagicMock()) + def datacenters_client(self, client: Client): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return DatacentersClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + datacenter_response, + ): + request_mock.return_value = datacenter_response - def test_get_by_id(self, datacenters_client, datacenter_response): - datacenters_client._client.request.return_value = datacenter_response datacenter = datacenters_client.get_by_id(1) - datacenters_client._client.request.assert_called_with( - url="/datacenters/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/datacenters/1", ) assert datacenter._client is datacenters_client assert datacenter.id == 1 @@ -74,15 +92,25 @@ def test_get_by_id(self, datacenters_client, datacenter_response): @pytest.mark.parametrize( "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, datacenters_client, two_datacenters_response, params): - datacenters_client._client.request.return_value = two_datacenters_response + def test_get_list( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + two_datacenters_response, + params, + ): + request_mock.return_value = two_datacenters_response + result = datacenters_client.get_list(**params) - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, ) datacenters = result.datacenters - assert result.meta is None + assert result.meta is not None assert len(datacenters) == 2 @@ -100,13 +128,23 @@ def test_get_list(self, datacenters_client, two_datacenters_response, params): assert isinstance(datacenter2.location, BoundLocation) @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) - def test_get_all(self, datacenters_client, two_datacenters_response, params): - datacenters_client._client.request.return_value = two_datacenters_response + def test_get_all( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + two_datacenters_response, + params, + ): + request_mock.return_value = two_datacenters_response + datacenters = datacenters_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, ) assert len(datacenters) == 2 @@ -124,13 +162,22 @@ def test_get_all(self, datacenters_client, two_datacenters_response, params): assert datacenter2.name == "nbg1-dc3" assert isinstance(datacenter2.location, BoundLocation) - def test_get_by_name(self, datacenters_client, one_datacenters_response): - datacenters_client._client.request.return_value = one_datacenters_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + one_datacenters_response, + ): + request_mock.return_value = one_datacenters_response + datacenter = datacenters_client.get_by_name("fsn1-dc8") params = {"name": "fsn1-dc8"} - datacenters_client._client.request.assert_called_with( - url="/datacenters", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/datacenters", + params=params, ) assert datacenter._client is datacenters_client diff --git a/tests/unit/datacenters/test_domain.py b/tests/unit/datacenters/test_domain.py new file mode 100644 index 00000000..fbf2b156 --- /dev/null +++ b/tests/unit/datacenters/test_domain.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import warnings + +import pytest + +from hcloud.datacenters import Datacenter, DatacenterServerTypes + +warnings.filterwarnings("ignore", category=DeprecationWarning) + + +@pytest.mark.parametrize( + "value", + [ + (Datacenter(id=1),), + (DatacenterServerTypes(available=[], available_for_migration=[], supported=[])), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/deprecation/__init__.py b/tests/unit/deprecation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/deprecation/test_domain.py b/tests/unit/deprecation/test_domain.py new file mode 100644 index 00000000..4ab020e9 --- /dev/null +++ b/tests/unit/deprecation/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.deprecation import DeprecationInfo + + +@pytest.mark.parametrize( + "value", + [ + (DeprecationInfo(),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/exp/__init__.py b/tests/unit/exp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/exp/test_zone.py b/tests/unit/exp/test_zone.py new file mode 100644 index 00000000..f787cd08 --- /dev/null +++ b/tests/unit/exp/test_zone.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import pytest + +from hcloud.exp.zone import format_txt_record, is_txt_record_quoted + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("hello world", False), + ('"hello world', False), + ('"hello world"', True), + ], +) +def test_is_txt_record_quoted(value: str, expected: bool): + assert is_txt_record_quoted(value) == expected + + +MANY_A = "a" * 255 +SOME_B = "b" * 10 + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("", ""), + ('""', '"\\"\\""'), + ("hello world", '"hello world"'), + ("hello\nworld", '"hello\nworld"'), + ('hello "world"', '"hello \\"world\\""'), + ('hello "world', '"hello \\"world"'), + (MANY_A + SOME_B, f'"{MANY_A}" "{SOME_B}"'), + ], +) +def test_format_txt_record(value: str, expected: str): + assert format_txt_record(value) == expected diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index 4e8cea94..79313c15 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -4,7 +4,7 @@ import pytest -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.firewalls import ( BoundFirewall, Firewall, @@ -15,31 +15,42 @@ ) from hcloud.servers import Server +from ..conftest import BoundModelTestCase -class TestBoundFirewall: - @pytest.fixture() - def bound_firewall(self, hetzner_client): - return BoundFirewall(client=hetzner_client.firewalls, data=dict(id=1)) - - def test_bound_firewall_init(self, firewall_response): - bound_firewall = BoundFirewall( - client=mock.MagicMock(), data=firewall_response["firewall"] - ) - assert bound_firewall.id == 38 - assert bound_firewall.name == "Corporate Intranet Protection" - assert bound_firewall.labels == {} - assert isinstance(bound_firewall.rules, list) - assert len(bound_firewall.rules) == 2 +class TestBoundFirewall(BoundModelTestCase): + methods = [ + BoundFirewall.update, + BoundFirewall.delete, + BoundFirewall.apply_to_resources, + BoundFirewall.remove_from_resources, + BoundFirewall.set_rules, + ] - assert isinstance(bound_firewall.applied_to, list) - assert len(bound_firewall.applied_to) == 2 - assert bound_firewall.applied_to[0].server.id == 42 - assert bound_firewall.applied_to[0].type == "server" - assert bound_firewall.applied_to[1].label_selector.selector == "key==value" - assert bound_firewall.applied_to[1].type == "label_selector" + @pytest.fixture() + def resource_client(self, client: Client): + return client.firewalls - firewall_in_rule = bound_firewall.rules[0] + @pytest.fixture() + def bound_model(self, resource_client, firewall_response): + return BoundFirewall(resource_client, data=firewall_response["firewall"]) + + def test_init(self, bound_model: BoundFirewall): + o = bound_model + assert o.id == 38 + assert o.name == "Corporate Intranet Protection" + assert o.labels == {} + assert isinstance(o.rules, list) + assert len(o.rules) == 2 + + assert isinstance(o.applied_to, list) + assert len(o.applied_to) == 2 + assert o.applied_to[0].server.id == 42 + assert o.applied_to[0].type == "server" + assert o.applied_to[1].label_selector.selector == "key==value" + assert o.applied_to[1].type == "label_selector" + + firewall_in_rule = o.rules[0] assert isinstance(firewall_in_rule, FirewallRule) assert firewall_in_rule.direction == FirewallRule.DIRECTION_IN assert firewall_in_rule.protocol == FirewallRule.PROTOCOL_TCP @@ -55,7 +66,7 @@ def test_bound_firewall_init(self, firewall_response): assert len(firewall_in_rule.destination_ips) == 0 assert firewall_in_rule.description == "allow http in" - firewall_out_rule = bound_firewall.rules[1] + firewall_out_rule = o.rules[1] assert isinstance(firewall_out_rule, FirewallRule) assert firewall_out_rule.direction == FirewallRule.DIRECTION_OUT assert firewall_out_rule.protocol == FirewallRule.PROTOCOL_TCP @@ -71,139 +82,25 @@ def test_bound_firewall_init(self, firewall_response): ] assert firewall_out_rule.description == "allow http out" - @pytest.mark.parametrize( - "params", [{}, {"sort": ["created"], "page": 1, "per_page": 2}] - ) - def test_get_actions_list( - self, hetzner_client, bound_firewall, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - result = bound_firewall.get_actions_list(**params) - hetzner_client.request.assert_called_with( - url="/firewalls/1/actions", method="GET", params=params - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - - @pytest.mark.parametrize("params", [{}, {"sort": ["created"]}]) - def test_get_actions( - self, hetzner_client, bound_firewall, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - actions = bound_firewall.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - hetzner_client.request.assert_called_with( - url="/firewalls/1/actions", method="GET", params=params - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - - def test_update(self, hetzner_client, bound_firewall, response_update_firewall): - hetzner_client.request.return_value = response_update_firewall - firewall = bound_firewall.update( - name="New Corporate Intranet Protection", labels={} - ) - hetzner_client.request.assert_called_with( - url="/firewalls/1", - method="PUT", - json={"name": "New Corporate Intranet Protection", "labels": {}}, - ) - - assert firewall.id == 38 - assert firewall.name == "New Corporate Intranet Protection" - - def test_delete(self, hetzner_client, bound_firewall): - delete_success = bound_firewall.delete() - hetzner_client.request.assert_called_with(url="/firewalls/1", method="DELETE") - - assert delete_success is True - - def test_set_rules(self, hetzner_client, bound_firewall, response_set_rules): - hetzner_client.request.return_value = response_set_rules - actions = bound_firewall.set_rules( - [ - FirewallRule( - direction=FirewallRule.DIRECTION_IN, - protocol=FirewallRule.PROTOCOL_ICMP, - source_ips=["0.0.0.0/0", "::/0"], - description="New firewall description", - ) - ] - ) - hetzner_client.request.assert_called_with( - url="/firewalls/1/actions/set_rules", - method="POST", - json={ - "rules": [ - { - "direction": "in", - "protocol": "icmp", - "source_ips": ["0.0.0.0/0", "::/0"], - "description": "New firewall description", - } - ] - }, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - - def test_apply_to_resources( - self, hetzner_client, bound_firewall, response_set_rules - ): - hetzner_client.request.return_value = response_set_rules - actions = bound_firewall.apply_to_resources( - [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] - ) - hetzner_client.request.assert_called_with( - url="/firewalls/1/actions/apply_to_resources", - method="POST", - json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - - def test_remove_from_resources( - self, hetzner_client, bound_firewall, response_set_rules - ): - hetzner_client.request.return_value = response_set_rules - actions = bound_firewall.remove_from_resources( - [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))] - ) - hetzner_client.request.assert_called_with( - url="/firewalls/1/actions/remove_from_resources", - method="POST", - json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, - ) - - assert actions[0].id == 13 - assert actions[0].progress == 100 - class TestFirewallsClient: @pytest.fixture() - def firewalls_client(self): - return FirewallsClient(client=mock.MagicMock()) + def firewalls_client(self, client: Client): + return FirewallsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall_response, + ): + request_mock.return_value = firewall_response - def test_get_by_id(self, firewalls_client, firewall_response): - firewalls_client._client.request.return_value = firewall_response firewall = firewalls_client.get_by_id(1) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/firewalls/1", ) assert firewall._client is firewalls_client assert firewall.id == 38 @@ -223,15 +120,25 @@ def test_get_by_id(self, firewalls_client, firewall_response): {}, ], ) - def test_get_list(self, firewalls_client, two_firewalls_response, params): - firewalls_client._client.request.return_value = two_firewalls_response + def test_get_list( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + two_firewalls_response, + params, + ): + request_mock.return_value = two_firewalls_response + result = firewalls_client.get_list(**params) - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, ) firewalls = result.firewalls - assert result.meta is None + assert result.meta is not None assert len(firewalls) == 2 @@ -257,14 +164,23 @@ def test_get_list(self, firewalls_client, two_firewalls_response, params): {}, ], ) - def test_get_all(self, firewalls_client, two_firewalls_response, params): - firewalls_client._client.request.return_value = two_firewalls_response + def test_get_all( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + two_firewalls_response, + params, + ): + request_mock.return_value = two_firewalls_response + firewalls = firewalls_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, ) assert len(firewalls) == 2 @@ -280,42 +196,36 @@ def test_get_all(self, firewalls_client, two_firewalls_response, params): assert firewalls2.id == 39 assert firewalls2.name == "Corporate Extranet Protection" - def test_get_by_name(self, firewalls_client, one_firewalls_response): - firewalls_client._client.request.return_value = one_firewalls_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + one_firewalls_response, + ): + request_mock.return_value = one_firewalls_response + firewall = firewalls_client.get_by_name("Corporate Intranet Protection") params = {"name": "Corporate Intranet Protection"} - firewalls_client._client.request.assert_called_with( - url="/firewalls", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, ) assert firewall._client is firewalls_client assert firewall.id == 38 assert firewall.name == "Corporate Intranet Protection" - @pytest.mark.parametrize( - "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list(self, firewalls_client, firewall, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions - result = firewalls_client.get_actions_list(firewall) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1/actions", method="GET", params={} - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == firewalls_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" + def test_create( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_create_firewall, + ): + request_mock.return_value = response_create_firewall - def test_create(self, firewalls_client, response_create_firewall): - firewalls_client._client.request.return_value = response_create_firewall response = firewalls_client.create( "Corporate Intranet Protection", rules=[ @@ -335,9 +245,10 @@ def test_create(self, firewalls_client, response_create_firewall): ), ], ) - firewalls_client._client.request.assert_called_with( - url="/firewalls", + + request_mock.assert_called_with( method="POST", + url="/firewalls", json={ "name": "Corporate Intranet Protection", "rules": [ @@ -366,14 +277,22 @@ def test_create(self, firewalls_client, response_create_firewall): @pytest.mark.parametrize( "firewall", [Firewall(id=38), BoundFirewall(mock.MagicMock(), dict(id=38))] ) - def test_update(self, firewalls_client, firewall, response_update_firewall): - firewalls_client._client.request.return_value = response_update_firewall + def test_update( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_update_firewall, + ): + request_mock.return_value = response_update_firewall + firewall = firewalls_client.update( firewall, name="New Corporate Intranet Protection", labels={} ) - firewalls_client._client.request.assert_called_with( - url="/firewalls/38", + + request_mock.assert_called_with( method="PUT", + url="/firewalls/38", json={"name": "New Corporate Intranet Protection", "labels": {}}, ) @@ -383,8 +302,15 @@ def test_update(self, firewalls_client, firewall, response_update_firewall): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_set_rules(self, firewalls_client, firewall, response_set_rules): - firewalls_client._client.request.return_value = response_set_rules + def test_set_rules( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, + ): + request_mock.return_value = response_set_rules + actions = firewalls_client.set_rules( firewall, [ @@ -392,19 +318,36 @@ def test_set_rules(self, firewalls_client, firewall, response_set_rules): direction=FirewallRule.DIRECTION_IN, protocol=FirewallRule.PROTOCOL_ICMP, source_ips=["0.0.0.0/0", "::/0"], - ) + description="Allow ICMP from everywhere", + ), + FirewallRule( + direction=FirewallRule.DIRECTION_IN, + protocol=FirewallRule.PROTOCOL_TCP, + port="80", + source_ips=["0.0.0.0/0", "::/0"], + description="Allow HTTP from everywhere", + ), ], ) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1/actions/set_rules", + + request_mock.assert_called_with( method="POST", + url="/firewalls/1/actions/set_rules", json={ "rules": [ { "direction": "in", "protocol": "icmp", "source_ips": ["0.0.0.0/0", "::/0"], - } + "description": "Allow ICMP from everywhere", + }, + { + "direction": "in", + "protocol": "tcp", + "port": "80", + "source_ips": ["0.0.0.0/0", "::/0"], + "description": "Allow HTTP from everywhere", + }, ] }, ) @@ -415,10 +358,17 @@ def test_set_rules(self, firewalls_client, firewall, response_set_rules): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, firewalls_client, firewall): + def test_delete( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + ): delete_success = firewalls_client.delete(firewall) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/firewalls/1", ) assert delete_success is True @@ -426,16 +376,23 @@ def test_delete(self, firewalls_client, firewall): @pytest.mark.parametrize( "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) - def test_apply_to_resources(self, firewalls_client, firewall, response_set_rules): - firewalls_client._client.request.return_value = response_set_rules + def test_apply_to_resources( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, + ): + request_mock.return_value = response_set_rules actions = firewalls_client.apply_to_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1/actions/apply_to_resources", + + request_mock.assert_called_with( method="POST", + url="/firewalls/1/actions/apply_to_resources", json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, ) @@ -446,69 +403,24 @@ def test_apply_to_resources(self, firewalls_client, firewall, response_set_rules "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] ) def test_remove_from_resources( - self, firewalls_client, firewall, response_set_rules + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + response_set_rules, ): - firewalls_client._client.request.return_value = response_set_rules + request_mock.return_value = response_set_rules actions = firewalls_client.remove_from_resources( firewall, [FirewallResource(type=FirewallResource.TYPE_SERVER, server=Server(id=5))], ) - firewalls_client._client.request.assert_called_with( - url="/firewalls/1/actions/remove_from_resources", + + request_mock.assert_called_with( method="POST", + url="/firewalls/1/actions/remove_from_resources", json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, ) assert actions[0].id == 13 assert actions[0].progress == 100 - - def test_actions_get_by_id(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = firewalls_client.actions.get_by_id(13) - - firewalls_client._client.request.assert_called_with( - url="/firewalls/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == firewalls_client._client.actions - assert action.id == 13 - assert action.command == "set_firewall_rules" - - def test_actions_get_list(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions - result = firewalls_client.actions.get_list() - - firewalls_client._client.request.assert_called_with( - url="/firewalls/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" - - def test_actions_get_all(self, firewalls_client, response_get_actions): - firewalls_client._client.request.return_value = response_get_actions - actions = firewalls_client.actions.get_all() - - firewalls_client._client.request.assert_called_with( - url="/firewalls/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == firewalls_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "set_firewall_rules" diff --git a/tests/unit/firewalls/test_domain.py b/tests/unit/firewalls/test_domain.py index 6c641222..7924fbb7 100644 --- a/tests/unit/firewalls/test_domain.py +++ b/tests/unit/firewalls/test_domain.py @@ -3,7 +3,29 @@ import datetime from datetime import timezone -from hcloud.firewalls import Firewall +import pytest + +from hcloud.firewalls import ( + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + FirewallResourceLabelSelector, + FirewallRule, +) + + +@pytest.mark.parametrize( + "value", + [ + (Firewall(id=1),), + (FirewallRule(direction="in", protocol="icmp", source_ips=[]),), + (FirewallResource(type="server"),), + (FirewallResourceAppliedToResources(type="server"),), + (FirewallResourceLabelSelector(),), + ], +) +def test_eq(value): + assert value.__eq__(value) class TestFirewall: diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index a56f0829..cc271c66 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -4,149 +4,95 @@ import pytest -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.floating_ips import BoundFloatingIP, FloatingIP, FloatingIPsClient from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server +from ..conftest import BoundModelTestCase -class TestBoundFloatingIP: - @pytest.fixture() - def bound_floating_ip(self, hetzner_client): - return BoundFloatingIP(client=hetzner_client.floating_ips, data=dict(id=14)) - - def test_bound_floating_ip_init(self, floating_ip_response): - bound_floating_ip = BoundFloatingIP( - client=mock.MagicMock(), data=floating_ip_response["floating_ip"] - ) - - assert bound_floating_ip.id == 4711 - assert bound_floating_ip.description == "Web Frontend" - assert bound_floating_ip.name == "Web Frontend" - assert bound_floating_ip.ip == "131.232.99.1" - assert bound_floating_ip.type == "ipv4" - assert bound_floating_ip.protection == {"delete": False} - assert bound_floating_ip.labels == {} - assert bound_floating_ip.blocked is False - - assert isinstance(bound_floating_ip.server, BoundServer) - assert bound_floating_ip.server.id == 42 - - assert isinstance(bound_floating_ip.home_location, BoundLocation) - assert bound_floating_ip.home_location.id == 1 - assert bound_floating_ip.home_location.name == "fsn1" - assert bound_floating_ip.home_location.description == "Falkenstein DC Park 1" - assert bound_floating_ip.home_location.country == "DE" - assert bound_floating_ip.home_location.city == "Falkenstein" - assert bound_floating_ip.home_location.latitude == 50.47612 - assert bound_floating_ip.home_location.longitude == 12.370071 - - def test_get_actions(self, hetzner_client, bound_floating_ip, response_get_actions): - hetzner_client.request.return_value = response_get_actions - actions = bound_floating_ip.get_actions(sort="id") - hetzner_client.request.assert_called_with( - url="/floating_ips/14/actions", - method="GET", - params={"sort": "id", "page": 1, "per_page": 50}, - ) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" +class TestBoundFloatingIP(BoundModelTestCase): + methods = [ + BoundFloatingIP.update, + BoundFloatingIP.delete, + BoundFloatingIP.change_protection, + BoundFloatingIP.change_dns_ptr, + BoundFloatingIP.assign, + BoundFloatingIP.unassign, + ] - def test_update( - self, hetzner_client, bound_floating_ip, response_update_floating_ip - ): - hetzner_client.request.return_value = response_update_floating_ip - floating_ip = bound_floating_ip.update( - description="New description", name="New name" - ) - hetzner_client.request.assert_called_with( - url="/floating_ips/14", - method="PUT", - json={"description": "New description", "name": "New name"}, - ) - - assert floating_ip.id == 4711 - assert floating_ip.description == "New description" - assert floating_ip.name == "New name" - - def test_delete(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_floating_ip.delete() - hetzner_client.request.assert_called_with( - url="/floating_ips/14", method="DELETE" - ) - - assert delete_success is True - - def test_change_protection(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_floating_ip.change_protection(True) - hetzner_client.request.assert_called_with( - url="/floating_ips/14/actions/change_protection", - method="POST", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_assign(self, hetzner_client, bound_floating_ip, server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_floating_ip.assign(server) - hetzner_client.request.assert_called_with( - url="/floating_ips/14/actions/assign", method="POST", json={"server": 1} - ) - assert action.id == 1 - assert action.progress == 0 + @pytest.fixture() + def resource_client(self, client: Client): + return client.floating_ips - def test_unassign(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_floating_ip.unassign() - hetzner_client.request.assert_called_with( - url="/floating_ips/14/actions/unassign", method="POST" + @pytest.fixture() + def bound_model(self, resource_client, floating_ip_response): + return BoundFloatingIP( + resource_client, data=floating_ip_response["floating_ip"] ) - assert action.id == 1 - assert action.progress == 0 - def test_change_dns_ptr(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - hetzner_client.request.assert_called_with( - url="/floating_ips/14/actions/change_dns_ptr", - method="POST", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, - ) - assert action.id == 1 - assert action.progress == 0 + def test_init(self, bound_model: BoundFloatingIP): + o = bound_model + assert o.id == 4711 + assert o.description == "Web Frontend" + assert o.name == "Web Frontend" + assert o.ip == "131.232.99.1" + assert o.type == "ipv4" + assert o.protection == {"delete": False} + assert o.labels == {} + assert o.blocked is False + + assert isinstance(o.server, BoundServer) + assert o.server.id == 42 + + assert isinstance(o.home_location, BoundLocation) + assert o.home_location.id == 1 + assert o.home_location.name == "fsn1" + assert o.home_location.description == "Falkenstein DC Park 1" + assert o.home_location.country == "DE" + assert o.home_location.city == "Falkenstein" + assert o.home_location.latitude == 50.47612 + assert o.home_location.longitude == 12.370071 class TestFloatingIPsClient: @pytest.fixture() - def floating_ips_client(self): - return FloatingIPsClient(client=mock.MagicMock()) + def floating_ips_client(self, client: Client): + return FloatingIPsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_response, + ): + request_mock.return_value = floating_ip_response - def test_get_by_id(self, floating_ips_client, floating_ip_response): - floating_ips_client._client.request.return_value = floating_ip_response bound_floating_ip = floating_ips_client.get_by_id(1) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/floating_ips/1", ) assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 assert bound_floating_ip.description == "Web Frontend" - def test_get_by_name(self, floating_ips_client, one_floating_ips_response): - floating_ips_client._client.request.return_value = one_floating_ips_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + one_floating_ips_response, + ): + request_mock.return_value = one_floating_ips_response + bound_floating_ip = floating_ips_client.get_by_name("Web Frontend") - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", method="GET", params={"name": "Web Frontend"} + + request_mock.assert_called_with( + method="GET", + url="/floating_ips", + params={"name": "Web Frontend"}, ) assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 @@ -157,15 +103,25 @@ def test_get_by_name(self, floating_ips_client, one_floating_ips_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, floating_ips_client, two_floating_ips_response, params): - floating_ips_client._client.request.return_value = two_floating_ips_response + def test_get_list( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + two_floating_ips_response, + params, + ): + request_mock.return_value = two_floating_ips_response + result = floating_ips_client.get_list(**params) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/floating_ips", + params=params, ) bound_floating_ips = result.floating_ips - assert result.meta is None + assert result.meta is not None assert len(bound_floating_ips) == 2 @@ -181,14 +137,23 @@ def test_get_list(self, floating_ips_client, two_floating_ips_response, params): assert bound_floating_ip2.description == "Web Backend" @pytest.mark.parametrize("params", [{"label_selector": "label1"}, {}]) - def test_get_all(self, floating_ips_client, two_floating_ips_response, params): - floating_ips_client._client.request.return_value = two_floating_ips_response + def test_get_all( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + two_floating_ips_response, + params, + ): + request_mock.return_value = two_floating_ips_response + bound_floating_ips = floating_ips_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/floating_ips", + params=params, ) assert len(bound_floating_ips) == 2 @@ -204,14 +169,21 @@ def test_get_all(self, floating_ips_client, two_floating_ips_response, params): assert bound_floating_ip2.id == 4712 assert bound_floating_ip2.description == "Web Backend" - def test_create_with_location(self, floating_ips_client, floating_ip_response): - floating_ips_client._client.request.return_value = floating_ip_response + def test_create_with_location( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_response, + ): + request_mock.return_value = floating_ip_response + response = floating_ips_client.create( "ipv6", "Web Frontend", home_location=Location(name="location") ) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", + + request_mock.assert_called_with( method="POST", + url="/floating_ips", json={ "description": "Web Frontend", "type": "ipv6", @@ -231,15 +203,21 @@ def test_create_with_location(self, floating_ips_client, floating_ip_response): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_create_with_server( - self, floating_ips_client, server, floating_ip_create_response + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip_create_response, ): - floating_ips_client._client.request.return_value = floating_ip_create_response + request_mock.return_value = floating_ip_create_response + response = floating_ips_client.create( type="ipv6", description="Web Frontend", server=server ) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", + + request_mock.assert_called_with( method="POST", + url="/floating_ips", json={"description": "Web Frontend", "type": "ipv6", "server": 1}, ) bound_floating_ip = response.floating_ip @@ -250,19 +228,21 @@ def test_create_with_server( assert bound_floating_ip.description == "Web Frontend" assert action.id == 13 - @pytest.mark.parametrize( - "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] - ) def test_create_with_name( - self, floating_ips_client, server, floating_ip_create_response + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_create_response, ): - floating_ips_client._client.request.return_value = floating_ip_create_response + request_mock.return_value = floating_ip_create_response + response = floating_ips_client.create( type="ipv6", description="Web Frontend", name="Web Frontend" ) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips", + + request_mock.assert_called_with( method="POST", + url="/floating_ips", json={ "description": "Web Frontend", "type": "ipv6", @@ -278,38 +258,25 @@ def test_create_with_name( assert bound_floating_ip.name == "Web Frontend" assert action.id == 13 - @pytest.mark.parametrize( - "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions(self, floating_ips_client, floating_ip, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions - actions = floating_ips_client.get_actions(floating_ip) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == floating_ips_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) def test_update( - self, floating_ips_client, floating_ip, response_update_floating_ip + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + response_update_floating_ip, ): - floating_ips_client._client.request.return_value = response_update_floating_ip + request_mock.return_value = response_update_floating_ip + floating_ip = floating_ips_client.update( floating_ip, description="New description", name="New name" ) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1", + + request_mock.assert_called_with( method="PUT", + url="/floating_ips/1", json={"description": "New description", "name": "New name"}, ) @@ -320,12 +287,20 @@ def test_update( @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + action_response, + ): + request_mock.return_value = action_response + action = floating_ips_client.change_protection(floating_ip, True) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1/actions/change_protection", + + request_mock.assert_called_with( method="POST", + url="/floating_ips/1/actions/change_protection", json={"delete": True}, ) @@ -335,11 +310,20 @@ def test_change_protection(self, floating_ips_client, floating_ip, generic_actio @pytest.mark.parametrize( "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + action_response, + ): + request_mock.return_value = action_response + delete_success = floating_ips_client.delete(floating_ip) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/floating_ips/1", ) assert delete_success is True @@ -354,11 +338,22 @@ def test_delete(self, floating_ips_client, floating_ip, generic_action): ), ], ) - def test_assign(self, floating_ips_client, server, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_assign( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip, + action_response, + ): + request_mock.return_value = action_response + action = floating_ips_client.assign(floating_ip, server) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/12/actions/assign", method="POST", json={"server": 1} + + request_mock.assert_called_with( + method="POST", + url="/floating_ips/12/actions/assign", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 @@ -367,11 +362,20 @@ def test_assign(self, floating_ips_client, server, floating_ip, generic_action): "floating_ip", [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], ) - def test_unassign(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_unassign( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + action_response, + ): + request_mock.return_value = action_response + action = floating_ips_client.unassign(floating_ip) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/12/actions/unassign", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/floating_ips/12/actions/unassign", ) assert action.id == 1 assert action.progress == 0 @@ -380,65 +384,23 @@ def test_unassign(self, floating_ips_client, floating_ip, generic_action): "floating_ip", [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], ) - def test_change_dns_ptr(self, floating_ips_client, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + action_response, + ): + request_mock.return_value = action_response + action = floating_ips_client.change_dns_ptr( floating_ip, "1.2.3.4", "server02.example.com" ) - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/12/actions/change_dns_ptr", + + request_mock.assert_called_with( method="POST", + url="/floating_ips/12/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = floating_ips_client.actions.get_by_id(13) - - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == floating_ips_client._client.actions - assert action.id == 13 - assert action.command == "assign_floating_ip" - - def test_actions_get_list(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions - result = floating_ips_client.actions.get_list() - - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - - def test_actions_get_all(self, floating_ips_client, response_get_actions): - floating_ips_client._client.request.return_value = response_get_actions - actions = floating_ips_client.actions.get_all() - - floating_ips_client._client.request.assert_called_with( - url="/floating_ips/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == floating_ips_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" diff --git a/tests/unit/floating_ips/test_domain.py b/tests/unit/floating_ips/test_domain.py index 69e50161..98be19e6 100644 --- a/tests/unit/floating_ips/test_domain.py +++ b/tests/unit/floating_ips/test_domain.py @@ -3,12 +3,24 @@ import datetime from datetime import timezone +import pytest + from hcloud.floating_ips import FloatingIP +@pytest.mark.parametrize( + "value", + [ + (FloatingIP(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestFloatingIP: def test_created_is_datetime(self): - floatingIP = FloatingIP(id=1, created="2016-01-30T23:50+00:00") - assert floatingIP.created == datetime.datetime( + floating_ip = FloatingIP(id=1, created="2016-01-30T23:50+00:00") + assert floating_ip.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 8e1dc490..d88b36bf 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -6,17 +6,29 @@ import pytest -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.images import BoundImage, Image, ImagesClient from hcloud.servers import BoundServer +from ..conftest import BoundModelTestCase + + +class TestBoundImage(BoundModelTestCase): + methods = [ + BoundImage.update, + BoundImage.delete, + BoundImage.change_protection, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.images -class TestBoundImage: @pytest.fixture() - def bound_image(self, hetzner_client): - return BoundImage(client=hetzner_client.images, data=dict(id=14)) + def bound_model(self, resource_client): + return BoundImage(resource_client, data=dict(id=14)) - def test_bound_image_init(self, image_response): + def test_init(self, image_response): bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) assert bound_image.id == 4711 @@ -46,93 +58,26 @@ def test_bound_image_init(self, image_response): assert bound_image.bound_to.id == 1 assert bound_image.bound_to.complete is False - @pytest.mark.parametrize( - "params", [{}, {"sort": ["status"], "page": 1, "per_page": 2}] - ) - def test_get_actions_list( - self, hetzner_client, bound_image, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - result = bound_image.get_actions_list(**params) - hetzner_client.request.assert_called_with( - url="/images/14/actions", method="GET", params=params - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - @pytest.mark.parametrize("params", [{}, {"sort": ["status"]}]) - def test_get_actions( - self, hetzner_client, bound_image, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - actions = bound_image.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - hetzner_client.request.assert_called_with( - url="/images/14/actions", method="GET", params=params - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_update(self, hetzner_client, bound_image, response_update_image): - hetzner_client.request.return_value = response_update_image - image = bound_image.update( - description="My new Image description", type="snapshot", labels={} - ) - hetzner_client.request.assert_called_with( - url="/images/14", - method="PUT", - json={ - "description": "My new Image description", - "type": "snapshot", - "labels": {}, - }, - ) - - assert image.id == 4711 - assert image.description == "My new Image description" - - def test_delete(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_image.delete() - hetzner_client.request.assert_called_with(url="/images/14", method="DELETE") - - assert delete_success is True - - def test_change_protection(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_image.change_protection(True) - hetzner_client.request.assert_called_with( - url="/images/14/actions/change_protection", - method="POST", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - class TestImagesClient: @pytest.fixture() - def images_client(self): - return ImagesClient(client=mock.MagicMock()) + def images_client(self, client: Client): + return ImagesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image_response, + ): + request_mock.return_value = image_response - def test_get_by_id(self, images_client, image_response): - images_client._client.request.return_value = image_response image = images_client.get_by_id(1) - images_client._client.request.assert_called_with(url="/images/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/images/1", + ) assert image._client is images_client assert image.id == 4711 assert image.name == "ubuntu-20.04" @@ -154,15 +99,25 @@ def test_get_by_id(self, images_client, image_response): {}, ], ) - def test_get_list(self, images_client, two_images_response, params): - images_client._client.request.return_value = two_images_response + def test_get_list( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + two_images_response, + params, + ): + request_mock.return_value = two_images_response + result = images_client.get_list(**params) - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, ) images = result.images - assert result.meta is None + assert result.meta is not None assert len(images) == 2 @@ -191,14 +146,23 @@ def test_get_list(self, images_client, two_images_response, params): {}, ], ) - def test_get_all(self, images_client, two_images_response, params): - images_client._client.request.return_value = two_images_response + def test_get_all( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + two_images_response, + params, + ): + request_mock.return_value = two_images_response + images = images_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, ) assert len(images) == 2 @@ -214,28 +178,45 @@ def test_get_all(self, images_client, two_images_response, params): assert images2.id == 4712 assert images2.name == "ubuntu-18.10" - def test_get_by_name(self, images_client, one_images_response): - images_client._client.request.return_value = one_images_response - image = images_client.get_by_name("ubuntu-20.04") + def test_get_by_name( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + one_images_response, + ): + request_mock.return_value = one_images_response + + with pytest.deprecated_call(): + image = images_client.get_by_name("ubuntu-20.04") params = {"name": "ubuntu-20.04"} - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, ) assert image._client is images_client assert image.id == 4711 assert image.name == "ubuntu-20.04" - def test_get_by_name_and_architecture(self, images_client, one_images_response): - images_client._client.request.return_value = one_images_response + def test_get_by_name_and_architecture( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + one_images_response, + ): + request_mock.return_value = one_images_response + image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") params = {"name": "ubuntu-20.04", "architecture": ["x86"]} - images_client._client.request.assert_called_with( - url="/images", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/images", + params=params, ) assert image._client is images_client @@ -246,34 +227,22 @@ def test_get_by_name_and_architecture(self, images_client, one_images_response): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, images_client, image, response_get_actions): - images_client._client.request.return_value = response_get_actions - result = images_client.get_actions_list(image) - images_client._client.request.assert_called_with( - url="/images/1/actions", method="GET", params={} - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == images_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + def test_update( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + response_update_image, + ): + request_mock.return_value = response_update_image - @pytest.mark.parametrize( - "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] - ) - def test_update(self, images_client, image, response_update_image): - images_client._client.request.return_value = response_update_image image = images_client.update( image, description="My new Image description", type="snapshot", labels={} ) - images_client._client.request.assert_called_with( - url="/images/1", + + request_mock.assert_called_with( method="PUT", + url="/images/1", json={ "description": "My new Image description", "type": "snapshot", @@ -287,12 +256,20 @@ def test_update(self, images_client, image, response_update_image): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, images_client, image, generic_action): - images_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + action_response, + ): + request_mock.return_value = action_response + action = images_client.change_protection(image, True) - images_client._client.request.assert_called_with( - url="/images/1/actions/change_protection", + + request_mock.assert_called_with( method="POST", + url="/images/1/actions/change_protection", json={"delete": True}, ) @@ -302,61 +279,20 @@ def test_change_protection(self, images_client, image, generic_action): @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, images_client, image, generic_action): - images_client._client.request.return_value = generic_action - delete_success = images_client.delete(image) - images_client._client.request.assert_called_with( - url="/images/1", method="DELETE" - ) - - assert delete_success is True - - def test_actions_get_by_id(self, images_client, response_get_actions): - images_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = images_client.actions.get_by_id(13) - - images_client._client.request.assert_called_with( - url="/images/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == images_client._client.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list(self, images_client, response_get_actions): - images_client._client.request.return_value = response_get_actions - result = images_client.actions.get_list() - - images_client._client.request.assert_called_with( - url="/images/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + def test_delete( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + action_response, + ): + request_mock.return_value = action_response - def test_actions_get_all(self, images_client, response_get_actions): - images_client._client.request.return_value = response_get_actions - actions = images_client.actions.get_all() + delete_success = images_client.delete(image) - images_client._client.request.assert_called_with( - url="/images/actions", - method="GET", - params={"page": 1, "per_page": 50}, + request_mock.assert_called_with( + method="DELETE", + url="/images/1", ) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == images_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + assert delete_success is True diff --git a/tests/unit/images/test_domain.py b/tests/unit/images/test_domain.py index 5c768877..4ff6c133 100644 --- a/tests/unit/images/test_domain.py +++ b/tests/unit/images/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.images import Image +@pytest.mark.parametrize( + "value", + [ + (Image(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestImage: def test_created_is_datetime(self): image = Image(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index fc64ab47..3735f26d 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -6,13 +6,14 @@ import pytest +from hcloud import Client from hcloud.isos import BoundIso, IsosClient class TestBoundIso: @pytest.fixture() - def bound_iso(self, hetzner_client): - return BoundIso(client=hetzner_client.isos, data=dict(id=14)) + def bound_iso(self, client: Client): + return BoundIso(client.isos, data=dict(id=14)) def test_bound_iso_init(self, iso_response): bound_iso = BoundIso(client=mock.MagicMock(), data=iso_response["iso"]) @@ -36,13 +37,23 @@ def test_bound_iso_init(self, iso_response): class TestIsosClient: @pytest.fixture() - def isos_client(self): - return IsosClient(client=mock.MagicMock()) + def isos_client(self, client: Client): + return IsosClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + iso_response, + ): + request_mock.return_value = iso_response - def test_get_by_id(self, isos_client, iso_response): - isos_client._client.request.return_value = iso_response iso = isos_client.get_by_id(1) - isos_client._client.request.assert_called_with(url="/isos/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/isos/1", + ) assert iso._client is isos_client assert iso.id == 4711 assert iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" @@ -55,15 +66,25 @@ def test_get_by_id(self, isos_client, iso_response): {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "page": 1, "per_page": 2}, ], ) - def test_get_list(self, isos_client, two_isos_response, params): - isos_client._client.request.return_value = two_isos_response + def test_get_list( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + two_isos_response, + params, + ): + request_mock.return_value = two_isos_response + result = isos_client.get_list(**params) - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, ) isos = result.isos - assert result.meta is None + assert result.meta is not None assert len(isos) == 2 @@ -81,14 +102,23 @@ def test_get_list(self, isos_client, two_isos_response, params): @pytest.mark.parametrize( "params", [{}, {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1"}] ) - def test_get_all(self, isos_client, two_isos_response, params): - isos_client._client.request.return_value = two_isos_response + def test_get_all( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + two_isos_response, + params, + ): + request_mock.return_value = two_isos_response + isos = isos_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, ) assert len(isos) == 2 @@ -104,14 +134,22 @@ def test_get_all(self, isos_client, two_isos_response, params): assert isos2.id == 4712 assert isos2.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - def test_get_by_name(self, isos_client, one_isos_response): - isos_client._client.request.return_value = one_isos_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + isos_client: IsosClient, + one_isos_response, + ): + request_mock.return_value = one_isos_response + iso = isos_client.get_by_name("FreeBSD-11.0-RELEASE-amd64-dvd1") params = {"name": "FreeBSD-11.0-RELEASE-amd64-dvd1"} - isos_client._client.request.assert_called_with( - url="/isos", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/isos", + params=params, ) assert iso._client is isos_client diff --git a/tests/unit/isos/test_domain.py b/tests/unit/isos/test_domain.py index 5017b529..f6c72a15 100644 --- a/tests/unit/isos/test_domain.py +++ b/tests/unit/isos/test_domain.py @@ -7,6 +7,16 @@ from hcloud.isos import Iso +@pytest.mark.parametrize( + "value", + [ + (Iso(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestIso: @pytest.fixture() def deprecated_iso(self): 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 23d2d14f..3c774a30 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -3,99 +3,141 @@ from unittest import mock import pytest +from dateutil.parser import isoparse +from hcloud import Client from hcloud.load_balancer_types import LoadBalancerTypesClient class TestLoadBalancerTypesClient: @pytest.fixture() - def load_balancer_types_client(self): - return LoadBalancerTypesClient(client=mock.MagicMock()) + def load_balancer_types_client(self, client: Client): + return LoadBalancerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + ): + request_mock.return_value = { + "load_balancer_type": load_balancer_type1, + } + + result = load_balancer_types_client.get_by_id(1) - def test_get_by_id(self, load_balancer_types_client, load_balancer_type_response): - load_balancer_types_client._client.request.return_value = ( - load_balancer_type_response + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types/1", ) - load_balancer_type = load_balancer_types_client.get_by_id(1) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types/1", method="GET" + + 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" ) - assert load_balancer_type._client is load_balancer_types_client - assert load_balancer_type.id == 1 - assert load_balancer_type.name == "LB11" @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, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + load_balancer_type2, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) - result = load_balancer_types_client.get_list(**params) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types", method="GET", params=params - ) + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2], + } - load_balancer_types = result.load_balancer_types - assert result.meta is None + result = load_balancer_types_client.get_list(**params) - assert len(load_balancer_types) == 2 + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, + ) - load_balancer_types1 = load_balancer_types[0] - load_balancer_types2 = load_balancer_types[1] + assert result.meta is not None + assert len(result.load_balancer_types) == 2 - 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, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + load_balancer_type2, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) - load_balancer_types = load_balancer_types_client.get_all(**params) + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2] + } + + result = load_balancer_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, ) - assert len(load_balancer_types) == 2 - - load_balancer_types1 = load_balancer_types[0] - load_balancer_types2 = load_balancer_types[1] + assert len(result) == 2 - assert load_balancer_types1._client is load_balancer_types_client - assert load_balancer_types1.id == 1 - assert load_balancer_types1.name == "LB11" + assert result[0]._client is load_balancer_types_client + assert result[0].id == 1 + assert result[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[1]._client is load_balancer_types_client + assert result[1].id == 2 + assert result[1].name == "lb21" def test_get_by_name( - self, load_balancer_types_client, one_load_balancer_types_response + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, ): - load_balancer_types_client._client.request.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") - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + 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_balancer_types/test_domain.py b/tests/unit/load_balancer_types/test_domain.py new file mode 100644 index 00000000..bbd0129c --- /dev/null +++ b/tests/unit/load_balancer_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.load_balancer_types import LoadBalancerType + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancerType(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index f19508ea..d7fe2a90 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -62,6 +62,7 @@ def response_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -86,7 +87,23 @@ def response_load_balancer(): "health_status": [{"listen_port": 443, "status": "healthy"}], "label_selector": None, "use_private_ip": False, - } + }, + { + "type": "label_selector", + "label_selector": {"selector": "env=prod"}, + "use_private_ip": True, + "targets": [ + { + "type": "server", + "server": {"id": 105054278}, + "use_private_ip": True, + "health_status": [ + {"listen_port": 443, "status": "healthy"}, + {"listen_port": 3000, "status": "healthy"}, + ], + } + ], + }, ], "algorithm": {"type": "round_robin"}, } @@ -139,6 +156,7 @@ def response_create_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -237,6 +255,7 @@ def response_update_load_balancer(): "certificates": [897], "redirect_http": True, "sticky_sessions": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -328,6 +347,7 @@ def response_simple_load_balancers(): "cookie_lifetime": 300, "certificates": [897], "redirect_http": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", @@ -412,6 +432,7 @@ def response_simple_load_balancers(): "cookie_lifetime": 300, "certificates": [897], "redirect_http": True, + "timeout_idle": 60, }, "health_check": { "protocol": "http", diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 3e9f281a..d9558f6d 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -4,7 +4,7 @@ import pytest -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.load_balancer_types import LoadBalancerType from hcloud.load_balancers import ( BoundLoadBalancer, @@ -13,21 +13,47 @@ LoadBalancerHealthCheck, LoadBalancersClient, LoadBalancerService, + LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, ) from hcloud.locations import Location from hcloud.networks import Network -from hcloud.servers import Server +from hcloud.servers import BoundServer, Server + +from ..conftest import BoundModelTestCase + + +class TestBoundLoadBalancer(BoundModelTestCase): + methods = [ + BoundLoadBalancer.update, + BoundLoadBalancer.delete, + BoundLoadBalancer.change_algorithm, + BoundLoadBalancer.change_dns_ptr, + BoundLoadBalancer.change_protection, + BoundLoadBalancer.change_type, + BoundLoadBalancer.add_service, + BoundLoadBalancer.update_service, + BoundLoadBalancer.delete_service, + BoundLoadBalancer.add_target, + BoundLoadBalancer.remove_target, + BoundLoadBalancer.attach_to_network, + BoundLoadBalancer.detach_from_network, + BoundLoadBalancer.disable_public_interface, + BoundLoadBalancer.enable_public_interface, + BoundLoadBalancer.get_metrics, + ] + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers -class TestBoundLoadBalancer: @pytest.fixture() - def bound_load_balancer(self, hetzner_client): - return BoundLoadBalancer(client=hetzner_client.load_balancers, data=dict(id=14)) + def bound_model(self, resource_client: LoadBalancersClient): + return BoundLoadBalancer(resource_client, data=dict(id=1)) - def test_bound_load_balancer_init(self, response_load_balancer): + def test_init(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( client=mock.MagicMock(), data=response_load_balancer["load_balancer"] ) @@ -35,82 +61,307 @@ def test_bound_load_balancer_init(self, response_load_balancer): assert bound_load_balancer.id == 4711 assert bound_load_balancer.name == "Web Frontend" - @pytest.mark.parametrize("params", [{"page": 1, "per_page": 10}, {}]) - def test_get_actions_list( - self, hetzner_client, bound_load_balancer, response_get_actions, params + def test_init_label_selector_nested_targets(self, response_load_balancer): + bound_load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), data=response_load_balancer["load_balancer"] + ) + + label_selector_target = bound_load_balancer.targets[1] + assert label_selector_target.type == "label_selector" + assert label_selector_target.label_selector.selector == "env=prod" + assert label_selector_target.use_private_ip is True + assert label_selector_target.targets is not None + assert len(label_selector_target.targets) == 1 + + nested = label_selector_target.targets[0] + assert nested.type == "server" + assert isinstance(nested.server, BoundServer) + assert nested.server.id == 105054278 + assert nested.use_private_ip is True + assert nested.health_status is not None + assert len(nested.health_status) == 2 + assert nested.health_status[0].listen_port == 443 + assert nested.health_status[0].status == "healthy" + assert nested.health_status[1].listen_port == 3000 + assert nested.health_status[1].status == "healthy" + + +class TestLoadBalancerslient: + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_load_balancer, + ): + request_mock.return_value = response_load_balancer + + bound_load_balancer = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers/1", + ) + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + assert bound_load_balancer.outgoing_traffic == 123456 + assert bound_load_balancer.ingoing_traffic == 123456 + assert bound_load_balancer.included_traffic == 654321 + + @pytest.mark.parametrize( + "params", + [ + { + "name": "load_balancer1", + "label_selector": "label1", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, ): - hetzner_client.request.return_value = response_get_actions - result = bound_load_balancer.get_actions_list(**params) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions", method="GET", params=params + request_mock.return_value = response_simple_load_balancers + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, ) - actions = result.actions - assert result.meta is None + bound_load_balancers = result.load_balancers + assert result.meta is not None + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" - @pytest.mark.parametrize("params", [{}]) - def test_get_actions( - self, hetzner_client, bound_load_balancer, response_get_actions, params + @pytest.mark.parametrize( + "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, ): - hetzner_client.request.return_value = response_get_actions - actions = bound_load_balancer.get_actions(**params) + request_mock.return_value = response_simple_load_balancers + + bound_load_balancers = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancer = resource_client.get_by_name("Web Frontend") + + params = {"name": "Web Frontend"} + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_create_load_balancer, + ): + request_mock.return_value = response_create_load_balancer + + response = resource_client.create( + "my-balancer", + load_balancer_type=LoadBalancerType(name="lb11"), + location=Location(id=1), + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers", + json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + ) + + bound_load_balancer = response.load_balancer + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 1 + assert bound_load_balancer.name == "my-balancer" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb11") + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb11"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type(load_balancer, LoadBalancerType(id=1)) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": 1}, ) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + assert action.id == 1 + assert action.progress == 0 + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_update( - self, hetzner_client, bound_load_balancer, response_update_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_update_load_balancer, ): - hetzner_client.request.return_value = response_update_load_balancer - load_balancer = bound_load_balancer.update(name="new-name", labels={}) - hetzner_client.request.assert_called_with( - url="/load_balancers/14", + request_mock.return_value = response_update_load_balancer + + load_balancer = resource_client.update( + load_balancer, name="new-name", labels={} + ) + + request_mock.assert_called_with( method="PUT", + url="/load_balancers/1", json={"name": "new-name", "labels": {}}, ) assert load_balancer.id == 4711 assert load_balancer.name == "new-name" - def test_delete(self, hetzner_client, generic_action, bound_load_balancer): - hetzner_client.request.return_value = generic_action - delete_success = bound_load_balancer.delete() - hetzner_client.request.assert_called_with( - url="/load_balancers/14", method="DELETE" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + delete_success = resource_client.delete(load_balancer) + + request_mock.assert_called_with( + method="DELETE", + url="/load_balancers/1", ) assert delete_success is True + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_get_metrics( self, - hetzner_client, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, response_get_metrics, - bound_load_balancer: BoundLoadBalancer, ): - hetzner_client.request.return_value = response_get_metrics - response = bound_load_balancer.get_metrics( + request_mock.return_value = response_get_metrics + + response = resource_client.get_metrics( + load_balancer, type=["requests_per_second"], start="2023-12-14T16:55:32+01:00", end="2023-12-14T16:55:32+01:00", ) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/metrics", + + request_mock.assert_called_with( method="GET", + url="/load_balancers/1/metrics", params={ "type": "requests_per_second", "start": "2023-12-14T16:55:32+01:00", @@ -120,38 +371,87 @@ def test_get_metrics( assert "requests_per_second" in response.metrics.time_series assert len(response.metrics.time_series["requests_per_second"]["values"]) == 3 + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_add_service( - self, hetzner_client, response_add_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_add_service, ): - hetzner_client.request.return_value = response_add_service - service = LoadBalancerService(listen_port=80, protocol="http") - action = bound_load_balancer.add_service(service) - hetzner_client.request.assert_called_with( - json={"protocol": "http", "listen_port": 80}, - url="/load_balancers/14/actions/add_service", + request_mock.return_value = response_add_service + + service = LoadBalancerService( + listen_port=80, + protocol="http", + destination_port=8080, + proxyprotocol=False, + http=LoadBalancerServiceHttp( + cookie_name="HCLBSTICKY", + cookie_lifetime=300, + redirect_http=True, + sticky_sessions=True, + timeout_idle=60, + ), + ) + action = resource_client.add_service(load_balancer, service) + + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/add_service", + json={ + "protocol": "http", + "listen_port": 80, + "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 assert action.progress == 100 assert action.command == "add_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_delete_service( - self, hetzner_client, response_delete_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_delete_service, ): - hetzner_client.request.return_value = response_delete_service + request_mock.return_value = response_delete_service + service = LoadBalancerService(listen_port=12) - action = bound_load_balancer.delete_service(service) - hetzner_client.request.assert_called_with( - json={"listen_port": 12}, - url="/load_balancers/14/actions/delete_service", + action = resource_client.delete_service(load_balancer, service) + + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/delete_service", + json={"listen_port": 12}, ) assert action.id == 13 assert action.progress == 100 assert action.command == "delete_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) @pytest.mark.parametrize( "target,params", [ @@ -175,19 +475,33 @@ def test_delete_service( ], ) def test_add_target( - self, hetzner_client, response_add_target, bound_load_balancer, target, params + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_add_target, + target, + params, ): - hetzner_client.request.return_value = response_add_target - action = bound_load_balancer.add_target(target) + request_mock.return_value = response_add_target + + action = resource_client.add_target(load_balancer, target) params.update({"type": target.type}) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions/add_target", method="POST", json=params + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/add_target", + json=params, ) assert action.id == 13 assert action.progress == 100 assert action.command == "add_target" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) @pytest.mark.parametrize( "target,params", [ @@ -212,34 +526,51 @@ def test_add_target( ) def test_remove_target( self, - hetzner_client, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, response_remove_target, - bound_load_balancer, target, params, ): - hetzner_client.request.return_value = response_remove_target - action = bound_load_balancer.remove_target(target) + request_mock.return_value = response_remove_target + + action = resource_client.remove_target(load_balancer, target) params.update({"type": target.type}) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions/remove_target", method="POST", json=params + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/remove_target", + json=params, ) assert action.id == 13 assert action.progress == 100 assert action.command == "remove_target" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_update_service( - self, hetzner_client, response_update_service, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_update_service, ): - hetzner_client.request.return_value = response_update_service + request_mock.return_value = response_update_service + new_health_check = LoadBalancerHealthCheck( protocol="http", port=13, interval=1, timeout=1, retries=1 ) service = LoadBalancerService(listen_port=12, health_check=new_health_check) - action = bound_load_balancer.update_service(service) - hetzner_client.request.assert_called_with( + action = resource_client.update_service(load_balancer, service) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/update_service", json={ "listen_port": 12, "health_check": { @@ -250,345 +581,210 @@ def test_update_service( "retries": 1, }, }, - url="/load_balancers/14/actions/update_service", - method="POST", ) assert action.id == 13 assert action.progress == 100 assert action.command == "update_service" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_algorithm( - self, hetzner_client, response_change_algorithm, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_change_algorithm, ): - hetzner_client.request.return_value = response_change_algorithm + request_mock.return_value = response_change_algorithm + algorithm = LoadBalancerAlgorithm(type="round_robin") - action = bound_load_balancer.change_algorithm(algorithm) - hetzner_client.request.assert_called_with( - json={"type": "round_robin"}, - url="/load_balancers/14/actions/change_algorithm", + action = resource_client.change_algorithm(load_balancer, algorithm) + + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/change_algorithm", + json={"type": "round_robin"}, ) assert action.id == 13 assert action.progress == 100 assert action.command == "change_algorithm" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_dns_ptr( - self, hetzner_client, response_change_reverse_dns_entry, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_change_reverse_dns_entry, ): - hetzner_client.request.return_value = response_change_reverse_dns_entry - action = bound_load_balancer.change_dns_ptr( - ip="1.2.3.4", dns_ptr="lb1.example.com" + request_mock.return_value = response_change_reverse_dns_entry + + action = resource_client.change_dns_ptr( + load_balancer, ip="1.2.3.4", dns_ptr="lb1.example.com" ) - hetzner_client.request.assert_called_with( - json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, - url="/load_balancers/14/actions/change_dns_ptr", + + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/change_dns_ptr", + json={"dns_ptr": "lb1.example.com", "ip": "1.2.3.4"}, ) assert action.id == 13 assert action.progress == 100 assert action.command == "change_dns_ptr" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_change_protection( - self, hetzner_client, response_change_protection, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_change_protection, ): - hetzner_client.request.return_value = response_change_protection - action = bound_load_balancer.change_protection(delete=True) - hetzner_client.request.assert_called_with( - json={"delete": True}, - url="/load_balancers/14/actions/change_protection", + request_mock.return_value = response_change_protection + + action = resource_client.change_protection(load_balancer, delete=True) + + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/change_protection", + json={"delete": True}, ) assert action.id == 13 assert action.progress == 100 assert action.command == "change_protection" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_enable_public_interface( - self, response_enable_public_interface, hetzner_client, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_enable_public_interface, ): - hetzner_client.request.return_value = response_enable_public_interface - action = bound_load_balancer.enable_public_interface() - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions/enable_public_interface", method="POST" + request_mock.return_value = response_enable_public_interface + + action = resource_client.enable_public_interface(load_balancer) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/enable_public_interface", ) assert action.id == 13 assert action.progress == 100 assert action.command == "enable_public_interface" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_disable_public_interface( - self, response_disable_public_interface, hetzner_client, bound_load_balancer + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_disable_public_interface, ): - hetzner_client.request.return_value = response_disable_public_interface - action = bound_load_balancer.disable_public_interface() - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions/disable_public_interface", method="POST" + request_mock.return_value = response_disable_public_interface + + action = resource_client.disable_public_interface(load_balancer) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/disable_public_interface", ) assert action.id == 13 assert action.progress == 100 assert action.command == "disable_public_interface" + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_attach_to_network( self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, response_attach_load_balancer_to_network, - hetzner_client, - bound_load_balancer, ): - hetzner_client.request.return_value = response_attach_load_balancer_to_network - action = bound_load_balancer.attach_to_network(Network(id=1)) - hetzner_client.request.assert_called_with( - json={"network": 1}, - url="/load_balancers/14/actions/attach_to_network", - method="POST", - ) + request_mock.return_value = response_attach_load_balancer_to_network - assert action.id == 13 - assert action.progress == 100 - assert action.command == "attach_to_network" + action = resource_client.attach_to_network(load_balancer, Network(id=1)) - def test_detach_from_network( - self, response_detach_from_network, hetzner_client, bound_load_balancer - ): - hetzner_client.request.return_value = response_detach_from_network - action = bound_load_balancer.detach_from_network(Network(id=1)) - hetzner_client.request.assert_called_with( - json={"network": 1}, - url="/load_balancers/14/actions/detach_from_network", + request_mock.assert_called_with( method="POST", + url="/load_balancers/1/actions/attach_to_network", + json={"network": 1}, ) assert action.id == 13 assert action.progress == 100 - assert action.command == "detach_from_network" - - def test_change_type(self, hetzner_client, bound_load_balancer, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_load_balancer.change_type(LoadBalancerType(name="lb21")) - hetzner_client.request.assert_called_with( - url="/load_balancers/14/actions/change_type", - method="POST", - json={"load_balancer_type": "lb21"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - -class TestLoadBalancerslient: - @pytest.fixture() - def load_balancers_client(self): - return LoadBalancersClient(client=mock.MagicMock()) - - def test_get_by_id(self, load_balancers_client, response_load_balancer): - load_balancers_client._client.request.return_value = response_load_balancer - bound_load_balancer = load_balancers_client.get_by_id(1) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/1", method="GET" - ) - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" - assert bound_load_balancer.outgoing_traffic == 123456 - assert bound_load_balancer.ingoing_traffic == 123456 - assert bound_load_balancer.included_traffic == 654321 - - @pytest.mark.parametrize( - "params", - [ - { - "name": "load_balancer1", - "label_selector": "label1", - "page": 1, - "per_page": 10, - }, - {"name": ""}, - {}, - ], - ) - def test_get_list( - self, load_balancers_client, response_simple_load_balancers, params - ): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) - result = load_balancers_client.get_list(**params) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers", method="GET", params=params - ) - - bound_load_balancers = result.load_balancers - assert result.meta is None - - assert len(bound_load_balancers) == 2 - - bound_load_balancer1 = bound_load_balancers[0] - bound_load_balancer2 = bound_load_balancers[1] - - assert bound_load_balancer1._client is load_balancers_client - assert bound_load_balancer1.id == 4711 - assert bound_load_balancer1.name == "Web Frontend" - - assert bound_load_balancer2._client is load_balancers_client - assert bound_load_balancer2.id == 4712 - assert bound_load_balancer2.name == "Web Frontend2" + assert action.command == "attach_to_network" @pytest.mark.parametrize( - "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], ) - def test_get_all( - self, load_balancers_client, response_simple_load_balancers, params + def test_detach_from_network( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_detach_from_network, ): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) - bound_load_balancers = load_balancers_client.get_all(**params) - - params.update({"page": 1, "per_page": 50}) + request_mock.return_value = response_detach_from_network - load_balancers_client._client.request.assert_called_with( - url="/load_balancers", method="GET", params=params - ) - - assert len(bound_load_balancers) == 2 + action = resource_client.detach_from_network(load_balancer, Network(id=1)) - bound_load_balancer1 = bound_load_balancers[0] - bound_load_balancer2 = bound_load_balancers[1] - - assert bound_load_balancer1._client is load_balancers_client - assert bound_load_balancer1.id == 4711 - assert bound_load_balancer1.name == "Web Frontend" - - assert bound_load_balancer2._client is load_balancers_client - assert bound_load_balancer2.id == 4712 - assert bound_load_balancer2.name == "Web Frontend2" - - def test_get_by_name(self, load_balancers_client, response_simple_load_balancers): - load_balancers_client._client.request.return_value = ( - response_simple_load_balancers - ) - bound_load_balancer = load_balancers_client.get_by_name("Web Frontend") - - params = {"name": "Web Frontend"} - - load_balancers_client._client.request.assert_called_with( - url="/load_balancers", method="GET", params=params - ) - - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" - - def test_create(self, load_balancers_client, response_create_load_balancer): - load_balancers_client._client.request.return_value = ( - response_create_load_balancer - ) - response = load_balancers_client.create( - "my-balancer", - load_balancer_type=LoadBalancerType(name="lb11"), - location=Location(id=1), - ) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers", + request_mock.assert_called_with( method="POST", - json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + url="/load_balancers/1/actions/detach_from_network", + json={"network": 1}, ) - bound_load_balancer = response.load_balancer - - assert bound_load_balancer._client is load_balancers_client - assert bound_load_balancer.id == 1 - assert bound_load_balancer.name == "my-balancer" + assert action.id == 13 + assert action.progress == 100 + assert action.command == "detach_from_network" @pytest.mark.parametrize( "load_balancer", [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], ) - def test_change_type_with_load_balancer_type_name( - self, load_balancers_client, load_balancer, generic_action + def test_change_type( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, ): - load_balancers_client._client.request.return_value = generic_action - action = load_balancers_client.change_type( - load_balancer, LoadBalancerType(name="lb11") - ) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/1/actions/change_type", - method="POST", - json={"load_balancer_type": "lb11"}, - ) + request_mock.return_value = action_response - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "load_balancer", - [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], - ) - def test_change_type_with_load_balancer_type_id( - self, load_balancers_client, load_balancer, generic_action - ): - load_balancers_client._client.request.return_value = generic_action - action = load_balancers_client.change_type( - load_balancer, LoadBalancerType(id=1) + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb21") ) - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/1/actions/change_type", + + request_mock.assert_called_with( method="POST", - json={"load_balancer_type": 1}, + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb21"}, ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = load_balancers_client.actions.get_by_id(13) - - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == load_balancers_client._client.actions - assert action.id == 13 - assert action.command == "change_protection" - - def test_actions_get_list(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = response_get_actions - result = load_balancers_client.actions.get_list() - - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_actions_get_all(self, load_balancers_client, response_get_actions): - load_balancers_client._client.request.return_value = response_get_actions - actions = load_balancers_client.actions.get_all() - - load_balancers_client._client.request.assert_called_with( - url="/load_balancers/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == load_balancers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index 24bed108..47b081f2 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -2,11 +2,90 @@ import datetime from datetime import timezone +from unittest import mock -from hcloud.load_balancers import LoadBalancer +import pytest + +from hcloud.load_balancers import ( + BoundLoadBalancer, + IPv4Address, + IPv6Network, + LoadBalancer, + LoadBalancerAlgorithm, + LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, + LoadBalancerService, + LoadBalancerServiceHttp, + LoadBalancerTarget, + LoadBalancerTargetHealthStatus, + LoadBalancerTargetIP, + LoadBalancerTargetLabelSelector, + PrivateNet, + PublicNetwork, +) +from hcloud.networks import Network + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancer(id=1),), + (LoadBalancerService,), + (LoadBalancerServiceHttp(),), + (LoadBalancerHealthCheck(),), + (LoadBalancerHealthCheckHttp(),), + (LoadBalancerTarget(),), + (LoadBalancerTargetHealthStatus(),), + (LoadBalancerTargetLabelSelector(),), + (LoadBalancerTargetIP(),), + (LoadBalancerAlgorithm(),), + ( + PublicNetwork( + ipv4=IPv4Address(ip="127.0.0.1", dns_ptr="example.com"), + ipv6=IPv6Network("2001:0db8::0/64", dns_ptr="example.com"), + enabled=True, + ), + ), + (IPv4Address(ip="127.0.0.1", dns_ptr="example.com"),), + (IPv6Network("2001:0db8::0/64", dns_ptr="example.com"),), + (PrivateNet(network=object(), ip="127.0.0.1"),), + ], +) +def test_eq(value): + assert value.__eq__(value) class TestLoadBalancers: def test_created_is_datetime(self): lb = LoadBalancer(id=1, created="2016-01-30T23:50+00:00") assert lb.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + load_balancer = LoadBalancer( + id=42, + private_net=[ + PrivateNet(network=network1, ip="127.0.0.1"), + PrivateNet(network=network2, ip="127.0.0.1"), + ], + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None + + load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + {"network": 1, "ip": "127.0.0.1"}, + {"network": 2, "ip": "127.0.0.1"}, + ], + }, + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index 9356707a..61150c42 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -1,22 +1,31 @@ from __future__ import annotations -from unittest import mock # noqa: F401 +from unittest import mock import pytest # noqa: F401 +from hcloud import Client from hcloud.locations import LocationsClient class TestLocationsClient: @pytest.fixture() - def locations_client(self): - return LocationsClient(client=mock.MagicMock()) + def locations_client(self, client: Client): + return LocationsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + location_response, + ): + request_mock.return_value = location_response - def test_get_by_id(self, locations_client, location_response): - locations_client._client.request.return_value = location_response location = locations_client.get_by_id(1) - locations_client._client.request.assert_called_with( - url="/locations/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/locations/1", ) assert location._client is locations_client assert location.id == 1 @@ -26,15 +35,25 @@ def test_get_by_id(self, locations_client, location_response): @pytest.mark.parametrize( "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, locations_client, two_locations_response, params): - locations_client._client.request.return_value = two_locations_response + def test_get_list( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response + result = locations_client.get_list(**params) - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, ) locations = result.locations - assert result.meta is None + assert result.meta is not None assert len(locations) == 2 @@ -52,14 +71,23 @@ def test_get_list(self, locations_client, two_locations_response, params): assert location2.network_zone == "eu-central" @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) - def test_get_all(self, locations_client, two_locations_response, params): - locations_client._client.request.return_value = two_locations_response + def test_get_all( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response + locations = locations_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, ) assert len(locations) == 2 @@ -77,14 +105,22 @@ def test_get_all(self, locations_client, two_locations_response, params): assert location2.name == "nbg1" assert location2.network_zone == "eu-central" - def test_get_by_name(self, locations_client, one_locations_response): - locations_client._client.request.return_value = one_locations_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + one_locations_response, + ): + request_mock.return_value = one_locations_response + location = locations_client.get_by_name("fsn1") params = {"name": "fsn1"} - locations_client._client.request.assert_called_with( - url="/locations", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/locations", + params=params, ) assert location._client is locations_client diff --git a/tests/unit/locations/test_domain.py b/tests/unit/locations/test_domain.py new file mode 100644 index 00000000..387e6264 --- /dev/null +++ b/tests/unit/locations/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.locations import Location + + +@pytest.mark.parametrize( + "value", + [ + (Location(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index ff4dd233..3f162b46 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -5,7 +5,7 @@ import pytest from dateutil.parser import isoparse -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.networks import ( BoundNetwork, Network, @@ -15,13 +15,30 @@ ) from hcloud.servers import BoundServer +from ..conftest import BoundModelTestCase + + +class TestBoundNetwork(BoundModelTestCase): + methods = [ + BoundNetwork.update, + BoundNetwork.delete, + BoundNetwork.add_subnet, + BoundNetwork.delete_subnet, + BoundNetwork.add_route, + BoundNetwork.delete_route, + BoundNetwork.change_ip_range, + BoundNetwork.change_protection, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.networks -class TestBoundNetwork: @pytest.fixture() - def bound_network(self, hetzner_client): - return BoundNetwork(client=hetzner_client.networks, data=dict(id=14)) + def bound_model(self, resource_client: NetworksClient): + return BoundNetwork(resource_client, data=dict(id=14)) - def test_bound_network_init(self, network_response): + def test_init(self, network_response): bound_network = BoundNetwork( client=mock.MagicMock(), data=network_response["network"] ) @@ -49,127 +66,11 @@ def test_bound_network_init(self, network_response): assert bound_network.routes[0].destination == "10.100.1.0/24" assert bound_network.routes[0].gateway == "10.0.1.1" - def test_get_actions(self, hetzner_client, bound_network, response_get_actions): - hetzner_client.request.return_value = response_get_actions - actions = bound_network.get_actions(sort="id") - hetzner_client.request.assert_called_with( - url="/networks/14/actions", - method="GET", - params={"page": 1, "per_page": 50, "sort": "id"}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" - - def test_update(self, hetzner_client, bound_network, response_update_network): - hetzner_client.request.return_value = response_update_network - network = bound_network.update(name="new-name") - hetzner_client.request.assert_called_with( - url="/networks/14", method="PUT", json={"name": "new-name"} - ) - - assert network.id == 4711 - assert network.name == "new-name" - - def test_delete(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_network.delete() - hetzner_client.request.assert_called_with(url="/networks/14", method="DELETE") - - assert delete_success is True - - def test_change_protection(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_network.change_protection(True) - hetzner_client.request.assert_called_with( - url="/networks/14/actions/change_protection", - method="POST", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_add_subnet(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - subnet = NetworkSubnet( - type=NetworkSubnet.TYPE_CLOUD, - ip_range="10.0.1.0/24", - network_zone="eu-central", - ) - action = bound_network.add_subnet(subnet) - hetzner_client.request.assert_called_with( - url="/networks/14/actions/add_subnet", - method="POST", - json={ - "type": NetworkSubnet.TYPE_CLOUD, - "ip_range": "10.0.1.0/24", - "network_zone": "eu-central", - }, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_delete_subnet(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - subnet = NetworkSubnet(ip_range="10.0.1.0/24") - action = bound_network.delete_subnet(subnet) - hetzner_client.request.assert_called_with( - url="/networks/14/actions/delete_subnet", - method="POST", - json={"ip_range": "10.0.1.0/24"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_add_route(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - action = bound_network.add_route(route) - hetzner_client.request.assert_called_with( - url="/networks/14/actions/add_route", - method="POST", - json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_delete_route(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - route = NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - action = bound_network.delete_route(route) - hetzner_client.request.assert_called_with( - url="/networks/14/actions/delete_route", - method="POST", - json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_ip(self, hetzner_client, bound_network, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_network.change_ip_range("10.0.0.0/12") - hetzner_client.request.assert_called_with( - url="/networks/14/actions/change_ip_range", - method="POST", - json={"ip_range": "10.0.0.0/12"}, - ) - - assert action.id == 1 - assert action.progress == 0 - class TestNetworksClient: @pytest.fixture() - def networks_client(self): - return NetworksClient(client=mock.MagicMock()) + def networks_client(self, client: Client): + return NetworksClient(client) @pytest.fixture() def network_subnet(self): @@ -192,11 +93,19 @@ def network_vswitch_subnet(self): def network_route(self): return NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") - def test_get_by_id(self, networks_client, network_response): - networks_client._client.request.return_value = network_response + def test_get_by_id( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_response, + ): + request_mock.return_value = network_response + bound_network = networks_client.get_by_id(1) - networks_client._client.request.assert_called_with( - url="/networks/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/networks/1", ) assert bound_network._client is networks_client assert bound_network.id == 1 @@ -206,15 +115,25 @@ def test_get_by_id(self, networks_client, network_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, networks_client, two_networks_response, params): - networks_client._client.request.return_value = two_networks_response + def test_get_list( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + two_networks_response, + params, + ): + request_mock.return_value = two_networks_response + result = networks_client.get_list(**params) - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, ) bound_networks = result.networks - assert result.meta is None + assert result.meta is not None assert len(bound_networks) == 2 @@ -230,14 +149,23 @@ def test_get_list(self, networks_client, two_networks_response, params): assert bound_network2.name == "myanothernet" @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) - def test_get_all(self, networks_client, two_networks_response, params): - networks_client._client.request.return_value = two_networks_response + def test_get_all( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + two_networks_response, + params, + ): + request_mock.return_value = two_networks_response + bound_networks = networks_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, ) assert len(bound_networks) == 2 @@ -253,41 +181,60 @@ def test_get_all(self, networks_client, two_networks_response, params): assert bound_network2.id == 2 assert bound_network2.name == "myanothernet" - def test_get_by_name(self, networks_client, one_network_response): - networks_client._client.request.return_value = one_network_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + one_network_response, + ): + request_mock.return_value = one_network_response + bound_network = networks_client.get_by_name("mynet") params = {"name": "mynet"} - networks_client._client.request.assert_called_with( - url="/networks", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, ) assert bound_network._client is networks_client assert bound_network.id == 1 assert bound_network.name == "mynet" - def test_create(self, networks_client, network_create_response): - networks_client._client.request.return_value = network_create_response + def test_create( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_create_response, + ): + request_mock.return_value = network_create_response + networks_client.create(name="mynet", ip_range="10.0.0.0/8") - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={"name": "mynet", "ip_range": "10.0.0.0/8"}, ) def test_create_with_expose_routes_to_vswitch( - self, networks_client, network_create_response_with_expose_routes_to_vswitch + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_create_response_with_expose_routes_to_vswitch, ): - networks_client._client.request.return_value = ( + request_mock.return_value = ( network_create_response_with_expose_routes_to_vswitch ) networks_client.create( name="mynet", ip_range="10.0.0.0/8", expose_routes_to_vswitch=True ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -296,15 +243,21 @@ def test_create_with_expose_routes_to_vswitch( ) def test_create_with_subnet( - self, networks_client, network_subnet, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -319,17 +272,23 @@ def test_create_with_subnet( ) def test_create_with_subnet_vswitch( - self, networks_client, network_subnet, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response + network_subnet.type = NetworkSubnet.TYPE_VSWITCH network_subnet.vswitch_id = 1000 networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -345,15 +304,21 @@ def test_create_with_subnet_vswitch( ) def test_create_with_route( - self, networks_client, network_route, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_route, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", routes=[network_route] ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -363,11 +328,12 @@ def test_create_with_route( def test_create_with_route_and_expose_routes_to_vswitch( self, + request_mock: mock.MagicMock, networks_client, network_route, network_create_response_with_expose_routes_to_vswitch, ): - networks_client._client.request.return_value = ( + request_mock.return_value = ( network_create_response_with_expose_routes_to_vswitch ) networks_client.create( @@ -376,9 +342,10 @@ def test_create_with_route_and_expose_routes_to_vswitch( routes=[network_route], expose_routes_to_vswitch=True, ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -388,18 +355,25 @@ def test_create_with_route_and_expose_routes_to_vswitch( ) def test_create_with_route_and_subnet( - self, networks_client, network_subnet, network_route, network_create_response + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_route, + network_create_response, ): - networks_client._client.request.return_value = network_create_response + request_mock.return_value = network_create_response + networks_client.create( name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet], routes=[network_route], ) - networks_client._client.request.assert_called_with( - url="/networks", + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", @@ -417,32 +391,22 @@ def test_create_with_route_and_subnet( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, networks_client, network, response_get_actions): - networks_client._client.request.return_value = response_get_actions - result = networks_client.get_actions_list(network, sort="id") - networks_client._client.request.assert_called_with( - url="/networks/1/actions", method="GET", params={"sort": "id"} - ) - - actions = result.actions - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == networks_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" + def test_update( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + response_update_network, + ): + request_mock.return_value = response_update_network - @pytest.mark.parametrize( - "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] - ) - def test_update(self, networks_client, network, response_update_network): - networks_client._client.request.return_value = response_update_network network = networks_client.update( network, name="new-name", expose_routes_to_vswitch=True ) - networks_client._client.request.assert_called_with( - url="/networks/1", + + request_mock.assert_called_with( method="PUT", + url="/networks/1", json={"name": "new-name", "expose_routes_to_vswitch": True}, ) @@ -453,12 +417,20 @@ def test_update(self, networks_client, network, response_update_network): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + ): + request_mock.return_value = action_response + action = networks_client.change_protection(network, True) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/change_protection", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/change_protection", json={"delete": True}, ) @@ -468,11 +440,20 @@ def test_change_protection(self, networks_client, network, generic_action): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + ): + request_mock.return_value = action_response + delete_success = networks_client.delete(network) - networks_client._client.request.assert_called_with( - url="/networks/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/networks/1", ) assert delete_success is True @@ -480,13 +461,21 @@ def test_delete(self, networks_client, network, generic_action): @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_add_subnet(self, networks_client, network, generic_action, network_subnet): - networks_client._client.request.return_value = generic_action + def test_add_subnet( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_subnet, + ): + request_mock.return_value = action_response action = networks_client.add_subnet(network, network_subnet) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/add_subnet", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/add_subnet", json={ "type": NetworkSubnet.TYPE_CLOUD, "ip_range": "10.0.1.0/24", @@ -501,14 +490,20 @@ def test_add_subnet(self, networks_client, network, generic_action, network_subn "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_add_subnet_vswitch( - self, networks_client, network, generic_action, network_vswitch_subnet + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_vswitch_subnet, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = action_response action = networks_client.add_subnet(network, network_vswitch_subnet) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/add_subnet", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/add_subnet", json={ "type": NetworkSubnet.TYPE_VSWITCH, "ip_range": "10.0.1.0/24", @@ -524,14 +519,20 @@ def test_add_subnet_vswitch( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_delete_subnet( - self, networks_client, network, generic_action, network_subnet + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_subnet, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = action_response action = networks_client.delete_subnet(network, network_subnet) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/delete_subnet", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/delete_subnet", json={"ip_range": "10.0.1.0/24"}, ) @@ -541,13 +542,21 @@ def test_delete_subnet( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_add_route(self, networks_client, network, generic_action, network_route): - networks_client._client.request.return_value = generic_action + def test_add_route( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_route, + ): + request_mock.return_value = action_response action = networks_client.add_route(network, network_route) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/add_route", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/add_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -558,14 +567,20 @@ def test_add_route(self, networks_client, network, generic_action, network_route "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) def test_delete_route( - self, networks_client, network, generic_action, network_route + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_route, ): - networks_client._client.request.return_value = generic_action + request_mock.return_value = action_response action = networks_client.delete_route(network, network_route) - networks_client._client.request.assert_called_with( - url="/networks/1/actions/delete_route", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/delete_route", json={"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}, ) @@ -575,64 +590,22 @@ def test_delete_route( @pytest.mark.parametrize( "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] ) - def test_change_ip_range(self, networks_client, network, generic_action): - networks_client._client.request.return_value = generic_action + def test_change_ip_range( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + ): + request_mock.return_value = action_response + action = networks_client.change_ip_range(network, "10.0.0.0/12") - networks_client._client.request.assert_called_with( - url="/networks/1/actions/change_ip_range", + + request_mock.assert_called_with( method="POST", + url="/networks/1/actions/change_ip_range", json={"ip_range": "10.0.0.0/12"}, ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id(self, networks_client, response_get_actions): - networks_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = networks_client.actions.get_by_id(13) - - networks_client._client.request.assert_called_with( - url="/networks/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == networks_client._client.actions - assert action.id == 13 - assert action.command == "add_subnet" - - def test_actions_get_list(self, networks_client, response_get_actions): - networks_client._client.request.return_value = response_get_actions - result = networks_client.actions.get_list() - - networks_client._client.request.assert_called_with( - url="/networks/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" - - def test_actions_get_all(self, networks_client, response_get_actions): - networks_client._client.request.return_value = response_get_actions - actions = networks_client.actions.get_all() - - networks_client._client.request.assert_called_with( - url="/networks/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == networks_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" diff --git a/tests/unit/networks/test_domain.py b/tests/unit/networks/test_domain.py index 1e5f10e3..dc3d3ba7 100644 --- a/tests/unit/networks/test_domain.py +++ b/tests/unit/networks/test_domain.py @@ -3,7 +3,21 @@ import datetime from datetime import timezone -from hcloud.networks import Network +import pytest + +from hcloud.networks import Network, NetworkRoute, NetworkSubnet + + +@pytest.mark.parametrize( + "value", + [ + (Network(id=1),), + (NetworkSubnet(ip_range="10.0.1.0/24"),), + (NetworkRoute(destination="10.0.1.2", gateway="10.0.1.1"),), + ], +) +def test_eq(value): + assert value.__eq__(value) class TestNetwork: diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 6ba5db5c..fc8cada8 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -4,10 +4,16 @@ import pytest -from hcloud.placement_groups import BoundPlacementGroup, PlacementGroupsClient +from hcloud import Client +from hcloud.placement_groups import ( + BoundPlacementGroup, + PlacementGroupsClient, +) +from ..conftest import BoundModelTestCase -def check_variables(placement_group, expected): + +def check_variables(placement_group: BoundPlacementGroup, expected): assert placement_group.id == expected["id"] assert placement_group.name == expected["name"] assert placement_group.labels == expected["labels"] @@ -15,14 +21,21 @@ def check_variables(placement_group, expected): assert placement_group.type == expected["type"] -class TestBoundPlacementGroup: +class TestBoundPlacementGroup(BoundModelTestCase): + methods = [ + BoundPlacementGroup.update, + BoundPlacementGroup.delete, + ] + @pytest.fixture() - def bound_placement_group(self, hetzner_client): - return BoundPlacementGroup( - client=hetzner_client.placement_groups, data=dict(id=897) - ) + def resource_client(self, client: Client): + return client.placement_groups + + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) - def test_bound_placement_group_init(self, placement_group_response): + def test_init(self, placement_group_response): bound_placement_group = BoundPlacementGroup( client=mock.MagicMock(), data=placement_group_response["placement_group"] ) @@ -31,54 +44,34 @@ def test_bound_placement_group_init(self, placement_group_response): bound_placement_group, placement_group_response["placement_group"] ) - def test_update( - self, hetzner_client, bound_placement_group, placement_group_response - ): - hetzner_client.request.return_value = placement_group_response - placement_group = bound_placement_group.update( - name=placement_group_response["placement_group"]["name"], - labels=placement_group_response["placement_group"]["labels"], - ) - hetzner_client.request.assert_called_with( - url="/placement_groups/{placement_group_id}".format( - placement_group_id=placement_group_response["placement_group"]["id"] - ), - method="PUT", - json={ - "labels": placement_group_response["placement_group"]["labels"], - "name": placement_group_response["placement_group"]["name"], - }, - ) - - check_variables(placement_group, placement_group_response["placement_group"]) - - def test_delete(self, hetzner_client, bound_placement_group): - delete_success = bound_placement_group.delete() - hetzner_client.request.assert_called_with( - url="/placement_groups/897", method="DELETE" - ) - - assert delete_success is True - class TestPlacementGroupsClient: @pytest.fixture() - def placement_groups_client(self): - return PlacementGroupsClient(client=mock.MagicMock()) + def resource_client(self, client: Client): + return client.placement_groups + + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + placement_group_response, + ): + request_mock.return_value = placement_group_response - def test_get_by_id(self, placement_groups_client, placement_group_response): - placement_groups_client._client.request.return_value = placement_group_response - placement_group = placement_groups_client.get_by_id( + placement_group = resource_client.get_by_id( placement_group_response["placement_group"]["id"] ) - placement_groups_client._client.request.assert_called_with( - url="/placement_groups/{placement_group_id}".format( - placement_group_id=placement_group_response["placement_group"]["id"] - ), + + request_mock.assert_called_with( method="GET", + url="/placement_groups/897", ) - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, placement_group_response["placement_group"]) @@ -97,18 +90,24 @@ def test_get_by_id(self, placement_groups_client, placement_group_response): ], ) def test_get_list( - self, placement_groups_client, two_placement_groups_response, params + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + two_placement_groups_response, + params, ): - placement_groups_client._client.request.return_value = ( - two_placement_groups_response - ) - result = placement_groups_client.get_list(**params) - placement_groups_client._client.request.assert_called_with( - url="/placement_groups", method="GET", params=params + request_mock.return_value = two_placement_groups_response + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/placement_groups", + params=params, ) placement_groups = result.placement_groups - assert result.meta is None + assert result.meta is not None assert len(placement_groups) == len( two_placement_groups_response["placement_groups"] @@ -117,7 +116,7 @@ def test_get_list( for placement_group, expected in zip( placement_groups, two_placement_groups_response["placement_groups"] ): - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, expected) @@ -133,16 +132,22 @@ def test_get_list( ], ) def test_get_all( - self, placement_groups_client, two_placement_groups_response, params + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + two_placement_groups_response, + params, ): - placement_groups_client._client.request.return_value = ( - two_placement_groups_response - ) - placement_groups = placement_groups_client.get_all(**params) + request_mock.return_value = two_placement_groups_response + + placement_groups = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - placement_groups_client._client.request.assert_called_with( - url="/placement_groups", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/placement_groups", + params=params, ) assert len(placement_groups) == len( @@ -152,32 +157,43 @@ def test_get_all( for placement_group, expected in zip( placement_groups, two_placement_groups_response["placement_groups"] ): - assert placement_group._client is placement_groups_client + assert placement_group._client is resource_client check_variables(placement_group, expected) - def test_get_by_name(self, placement_groups_client, one_placement_group_response): - placement_groups_client._client.request.return_value = ( - one_placement_group_response - ) - placement_group = placement_groups_client.get_by_name( + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + one_placement_group_response, + ): + request_mock.return_value = one_placement_group_response + + placement_group = resource_client.get_by_name( one_placement_group_response["placement_groups"][0]["name"] ) params = {"name": one_placement_group_response["placement_groups"][0]["name"]} - placement_groups_client._client.request.assert_called_with( - url="/placement_groups", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/placement_groups", + params=params, ) check_variables( placement_group, one_placement_group_response["placement_groups"][0] ) - def test_create(self, placement_groups_client, response_create_placement_group): - placement_groups_client._client.request.return_value = ( - response_create_placement_group - ) - response = placement_groups_client.create( + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + response_create_placement_group, + ): + request_mock.return_value = response_create_placement_group + + response = resource_client.create( name=response_create_placement_group["placement_group"]["name"], type=response_create_placement_group["placement_group"]["type"], labels=response_create_placement_group["placement_group"]["labels"], @@ -188,13 +204,57 @@ def test_create(self, placement_groups_client, response_create_placement_group): "labels": response_create_placement_group["placement_group"]["labels"], "type": response_create_placement_group["placement_group"]["type"], } - placement_groups_client._client.request.assert_called_with( - url="/placement_groups", method="POST", json=json + + request_mock.assert_called_with( + method="POST", + url="/placement_groups", + json=json, ) bound_placement_group = response.placement_group - assert bound_placement_group._client is placement_groups_client + assert bound_placement_group._client is resource_client check_variables( bound_placement_group, response_create_placement_group["placement_group"] ) + + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + bound_model, + placement_group_response, + ): + request_mock.return_value = placement_group_response + + placement_group = resource_client.update( + bound_model, + name=placement_group_response["placement_group"]["name"], + labels=placement_group_response["placement_group"]["labels"], + ) + + request_mock.assert_called_with( + method="PUT", + url="/placement_groups/897", + json={ + "labels": placement_group_response["placement_group"]["labels"], + "name": placement_group_response["placement_group"]["name"], + }, + ) + + check_variables(placement_group, placement_group_response["placement_group"]) + + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + bound_model, + ): + delete_success = resource_client.delete(bound_model) + + request_mock.assert_called_with( + method="DELETE", + url="/placement_groups/897", + ) + + assert delete_success is True diff --git a/tests/unit/placement_groups/test_domain.py b/tests/unit/placement_groups/test_domain.py index 91fd0c3d..0d25b54f 100644 --- a/tests/unit/placement_groups/test_domain.py +++ b/tests/unit/placement_groups/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.placement_groups import PlacementGroup +@pytest.mark.parametrize( + "value", + [ + (PlacementGroup(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestPlacementGroup: def test_created_is_datetime(self): placement_group = PlacementGroup(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/primary_ips/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 413ed2bb..f8df0225 100644 --- a/tests/unit/primary_ips/test_client.py +++ b/tests/unit/primary_ips/test_client.py @@ -4,345 +4,398 @@ import pytest -from hcloud.actions import BoundAction -from hcloud.datacenters import BoundDatacenter, Datacenter +from hcloud import Client +from hcloud.locations import BoundLocation, Location from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient +from ..conftest import BoundModelTestCase, assert_bound_action1 -class TestBoundPrimaryIP: - @pytest.fixture() - def bound_primary_ip(self, hetzner_client): - return BoundPrimaryIP(client=hetzner_client.primary_ips, data=dict(id=14)) - def test_bound_primary_ip_init(self, primary_ip_response): - bound_primary_ip = BoundPrimaryIP( - client=mock.MagicMock(), data=primary_ip_response["primary_ip"] - ) +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" - assert bound_primary_ip.id == 42 - assert bound_primary_ip.name == "my-resource" - assert bound_primary_ip.ip == "131.232.99.1" - assert bound_primary_ip.type == "ipv4" - assert bound_primary_ip.protection == {"delete": False} - assert bound_primary_ip.labels == {} - assert bound_primary_ip.blocked is False - - assert bound_primary_ip.assignee_id == 17 - assert bound_primary_ip.assignee_type == "server" - - assert isinstance(bound_primary_ip.datacenter, BoundDatacenter) - assert bound_primary_ip.datacenter.id == 42 - assert bound_primary_ip.datacenter.name == "fsn1-dc8" - assert bound_primary_ip.datacenter.description == "Falkenstein DC Park 8" - assert bound_primary_ip.datacenter.location.country == "DE" - assert bound_primary_ip.datacenter.location.city == "Falkenstein" - assert bound_primary_ip.datacenter.location.latitude == 50.47612 - assert bound_primary_ip.datacenter.location.longitude == 12.370071 - - def test_update(self, hetzner_client, bound_primary_ip, response_update_primary_ip): - hetzner_client.request.return_value = response_update_primary_ip - primary_ip = bound_primary_ip.update(auto_delete=True, name="my-resource") - hetzner_client.request.assert_called_with( - url="/primary_ips/14", - method="PUT", - json={"auto_delete": True, "name": "my-resource"}, - ) - assert primary_ip.id == 42 - assert primary_ip.auto_delete is True +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" - def test_delete(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_primary_ip.delete() - hetzner_client.request.assert_called_with( - url="/primary_ips/14", method="DELETE" - ) - assert delete_success is True +class TestBoundPrimaryIP(BoundModelTestCase): + methods = [ + BoundPrimaryIP.update, + BoundPrimaryIP.delete, + BoundPrimaryIP.change_dns_ptr, + BoundPrimaryIP.change_protection, + BoundPrimaryIP.assign, + BoundPrimaryIP.unassign, + ] - def test_change_protection(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_primary_ip.change_protection(True) - hetzner_client.request.assert_called_with( - url="/primary_ips/14/actions/change_protection", - method="POST", - json={"delete": True}, - ) + @pytest.fixture() + def resource_client(self, client: Client): + return client.primary_ips - assert action.id == 1 - assert action.progress == 0 + @pytest.fixture() + def bound_model(self, resource_client: PrimaryIPsClient): + return BoundPrimaryIP(resource_client, data=dict(id=14)) - def test_assign(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_primary_ip.assign(assignee_id=12, assignee_type="server") - hetzner_client.request.assert_called_with( - url="/primary_ips/14/actions/assign", - method="POST", - json={"assignee_id": 12, "assignee_type": "server"}, - ) - assert action.id == 1 - assert action.progress == 0 - - def test_unassign(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_primary_ip.unassign() - hetzner_client.request.assert_called_with( - url="/primary_ips/14/actions/unassign", method="POST" - ) - assert action.id == 1 - assert action.progress == 0 - - def test_change_dns_ptr(self, hetzner_client, bound_primary_ip, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_primary_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - hetzner_client.request.assert_called_with( - url="/primary_ips/14/actions/change_dns_ptr", - method="POST", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, - ) - assert action.id == 1 - assert action.progress == 0 + def test_init(self, primary_ip1): + o = BoundPrimaryIP(client=mock.MagicMock(), data=primary_ip1) + + assert o.id == 42 + assert o.name == "primary-ip1" + assert o.ip == "131.232.99.1" + assert o.type == "ipv4" + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert o.blocked is False + + assert o.assignee_id == 17 + assert o.assignee_type == "server" + + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" class TestPrimaryIPsClient: @pytest.fixture() - def primary_ips_client(self): - return PrimaryIPsClient(client=mock.MagicMock()) - - def test_get_by_id(self, primary_ips_client, primary_ip_response): - primary_ips_client._client.request.return_value = primary_ip_response - bound_primary_ip = primary_ips_client.get_by_id(1) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1", method="GET" + def resource_client(self, client: Client): + return PrimaryIPsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = {"primary_ip": primary_ip1} + + 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 - - def test_get_by_name(self, primary_ips_client, one_primary_ips_response): - primary_ips_client._client.request.return_value = one_primary_ips_response - bound_primary_ip = primary_ips_client.get_by_name("my-resource") - primary_ips_client._client.request.assert_called_with( - url="/primary_ips", method="GET", params={"name": "my-resource"} + assert_bound_primary_ip1(result, resource_client) + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = {"primary_ips": [primary_ip1]} + + result = resource_client.get_by_name("primary-ip1") + + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + 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"}]) - def test_get_all(self, primary_ips_client, all_primary_ips_response, params): - primary_ips_client._client.request.return_value = all_primary_ips_response - bound_primary_ips = primary_ips_client.get_all(**params) + @pytest.mark.parametrize( + "params", + [ + {"name": "primary-ip1"}, + {"label_selector": "key=value"}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + primary_ip2, + params, + ): + request_mock.return_value = {"primary_ips": [primary_ip1, primary_ip2]} + + result = resource_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params=params, ) - assert len(bound_primary_ips) == 1 + assert len(result) == 2 + assert_bound_primary_ip1(result[0], resource_client) + assert_bound_primary_ip2(result[1], resource_client) - bound_primary_ip1 = bound_primary_ips[0] - - 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_location( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": None, + } - def test_create_with_datacenter(self, primary_ips_client, primary_ip_response): - primary_ips_client._client.request.return_value = primary_ip_response - response = primary_ips_client.create( - type="ipv6", name="my-resource", datacenter=Datacenter(name="datacenter") + result = resource_client.create( + type="ipv4", + name="primary-ip1", + location=Location(name="fsn1"), ) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips", + + request_mock.assert_called_with( method="POST", + url="/primary_ips", json={ - "name": "my-resource", - "type": "ipv6", - "datacenter": "datacenter", + "name": "primary-ip1", + "type": "ipv4", + "location": "fsn1", "auto_delete": False, - "assignee_type": "server", }, ) - - 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, primary_ips_client, primary_ip_create_response + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + action1_running, ): - primary_ips_client._client.request.return_value = primary_ip_create_response - response = primary_ips_client.create( - type="ipv6", - name="my-ip", - assignee_id=1, + 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", - datacenter=Datacenter(name="datacenter"), ) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips", + + request_mock.assert_called_with( method="POST", + url="/primary_ips", json={ - "type": "ipv6", - "assignee_id": 1, + "name": "primary-ip1", + "type": "ipv4", + "assignee_id": 17, "assignee_type": "server", - "name": "my-ip", "auto_delete": False, - "datacenter": "datacenter", }, ) - 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, primary_ips_client, primary_ip, response_update_primary_ip): - primary_ips_client._client.request.return_value = response_update_primary_ip - primary_ip = primary_ips_client.update( - primary_ip, auto_delete=True, name="my-resource" + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + primary_ip1, + ): + request_mock.return_value = {"primary_ip": primary_ip1} + + result = resource_client.update( + primary_ip, + name="changed", + auto_delete=True, ) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1", + + request_mock.assert_called_with( method="PUT", - 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(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action - action = primary_ips_client.change_protection(primary_ip, True) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1/actions/change_protection", - method="POST", - json={"delete": True}, + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + ): + request_mock.return_value = None + + result = resource_client.delete(primary_ip) + + request_mock.assert_called_with( + 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(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action - delete_success = primary_ips_client.delete(primary_ip) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/1", method="DELETE" + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + result = resource_client.change_protection( + primary_ip, + delete=True, ) - assert delete_success is True + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/change_protection", + json={ + "delete": 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, primary_ips_client, assignee_id, assignee_type, primary_ip, generic_action + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, ): - primary_ips_client._client.request.return_value = generic_action - action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/12/actions/assign", - method="POST", - json={"assignee_id": 1, "assignee_type": "server"}, + request_mock.return_value = action_response + + result = resource_client.assign( + primary_ip, + assignee_id=17, + assignee_type="server", ) - assert action.id == 1 - assert action.progress == 0 - @pytest.mark.parametrize( - "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] - ) - def test_unassign(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action - action = primary_ips_client.unassign(primary_ip) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/12/actions/unassign", method="POST" + request_mock.assert_called_with( + method="POST", + 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_change_dns_ptr(self, primary_ips_client, primary_ip, generic_action): - primary_ips_client._client.request.return_value = generic_action - action = primary_ips_client.change_dns_ptr( - primary_ip, "1.2.3.4", "server02.example.com" - ) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/12/actions/change_dns_ptr", - method="POST", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, - ) - assert action.id == 1 - assert action.progress == 0 + def test_unassign( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response - def test_actions_get_by_id(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = primary_ips_client.actions.get_by_id(13) + result = resource_client.unassign(primary_ip) - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/actions/13", method="GET" + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/unassign", ) + assert_bound_action1(result, resource_client._parent.actions) - assert isinstance(action, BoundAction) - assert action._client == primary_ips_client._client.actions - assert action.id == 13 - assert action.command == "assign_primary_ip" - - def test_actions_get_list(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = response_get_actions - result = primary_ips_client.actions.get_list() + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/actions", - method="GET", - params={}, + result = resource_client.change_dns_ptr( + primary_ip, + ip="1.2.3.4", + dns_ptr="server02.example.com", ) - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_primary_ip" - - def test_actions_get_all(self, primary_ips_client, response_get_actions): - primary_ips_client._client.request.return_value = response_get_actions - actions = primary_ips_client.actions.get_all() - - primary_ips_client._client.request.assert_called_with( - url="/primary_ips/actions", - method="GET", - params={"page": 1, "per_page": 50}, + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/change_dns_ptr", + json={ + "ip": "1.2.3.4", + "dns_ptr": "server02.example.com", + }, ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == primary_ips_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "assign_primary_ip" + assert_bound_action1(result, resource_client._parent.actions) diff --git a/tests/unit/primary_ips/test_domain.py b/tests/unit/primary_ips/test_domain.py index 945522fd..c1fadf0a 100644 --- a/tests/unit/primary_ips/test_domain.py +++ b/tests/unit/primary_ips/test_domain.py @@ -3,12 +3,24 @@ import datetime from datetime import timezone +import pytest + from hcloud.primary_ips import PrimaryIP +@pytest.mark.parametrize( + "value", + [ + (PrimaryIP(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestPrimaryIP: def test_created_is_datetime(self): - primaryIP = PrimaryIP(id=1, created="2016-01-30T23:50+00:00") - assert primaryIP.created == datetime.datetime( + primary_ip = PrimaryIP(id=1, created="2016-01-30T23:50+00:00") + assert primary_ip.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/server_types/conftest.py b/tests/unit/server_types/conftest.py index 7f4b1344..095ab7f4 100644 --- a/tests/unit/server_types/conftest.py +++ b/tests/unit/server_types/conftest.py @@ -10,6 +10,7 @@ def server_type_response(): "id": 1, "name": "cx11", "description": "CX11", + "category": "Shared vCPU", "cores": 1, "memory": 1, "disk": 25, @@ -32,9 +33,28 @@ def server_type_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, + "locations": [ + { + "id": 1, + "name": "nbg1", + "deprecation": None, + "available": True, + "recommended": False, + }, + { + "id": 2, + "name": "fsn1", + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + "available": True, + "recommended": True, + }, + ], } } @@ -69,8 +89,8 @@ def two_server_types_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, }, { @@ -145,8 +165,8 @@ def one_server_types_response(): "included_traffic": 21990232555520, "deprecated": True, "deprecation": { - "announced": "2023-06-01T00:00:00+00:00", - "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", }, } ] diff --git a/tests/unit/server_types/test_client.py b/tests/unit/server_types/test_client.py index 54ab3a9b..b8d266ff 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -5,49 +5,77 @@ import pytest +from hcloud import Client from hcloud.server_types import BoundServerType, ServerTypesClient class TestBoundServerType: @pytest.fixture() - def bound_server_type(self, hetzner_client): - return BoundServerType(client=hetzner_client.server_types, data=dict(id=14)) + def bound_server_type(self, client: Client): + return BoundServerType(client.server_types, data=dict(id=14)) - def test_bound_server_type_init(self, server_type_response): - bound_server_type = BoundServerType( + def test_init(self, server_type_response): + o = BoundServerType( client=mock.MagicMock(), data=server_type_response["server_type"] ) - assert bound_server_type.id == 1 - assert bound_server_type.name == "cx11" - assert bound_server_type.description == "CX11" - assert bound_server_type.cores == 1 - assert bound_server_type.memory == 1 - assert bound_server_type.disk == 25 - assert bound_server_type.storage_type == "local" - assert bound_server_type.cpu_type == "shared" - assert bound_server_type.architecture == "x86" - assert bound_server_type.deprecated is True - assert bound_server_type.deprecation is not None - assert bound_server_type.deprecation.announced == datetime( - 2023, 6, 1, tzinfo=timezone.utc + assert o.id == 1 + assert o.name == "cx11" + assert o.description == "CX11" + assert o.category == "Shared vCPU" + assert o.cores == 1 + assert o.memory == 1 + assert o.disk == 25 + assert o.storage_type == "local" + assert o.cpu_type == "shared" + assert o.architecture == "x86" + assert len(o.locations) == 2 + assert o.locations[0].location.id == 1 + assert o.locations[0].location.name == "nbg1" + assert o.locations[0].deprecation is None + assert o.locations[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 ( + o.locations[1].deprecation.announced.isoformat() + == "2023-06-01T00:00:00+00:00" ) - assert bound_server_type.deprecation.unavailable_after == datetime( - 2023, 9, 1, tzinfo=timezone.utc + assert ( + o.locations[1].deprecation.unavailable_after.isoformat() + == "2023-09-01T00:00:00+00:00" ) - assert bound_server_type.included_traffic == 21990232555520 + assert o.locations[1].available is True + assert o.locations[1].recommended is True + + with pytest.deprecated_call(): + assert o.deprecated is True + assert o.deprecation is not None + assert o.deprecation.announced == datetime(2023, 6, 1, tzinfo=timezone.utc) + assert o.deprecation.unavailable_after == datetime( + 2023, 9, 1, tzinfo=timezone.utc + ) + assert o.included_traffic == 21990232555520 class TestServerTypesClient: @pytest.fixture() - def server_types_client(self): - return ServerTypesClient(client=mock.MagicMock()) + def server_types_client(self, client: Client): + return ServerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + server_type_response, + ): + request_mock.return_value = server_type_response - def test_get_by_id(self, server_types_client, server_type_response): - server_types_client._client.request.return_value = server_type_response server_type = server_types_client.get_by_id(1) - server_types_client._client.request.assert_called_with( - url="/server_types/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/server_types/1", ) assert server_type._client is server_types_client assert server_type.id == 1 @@ -56,15 +84,25 @@ def test_get_by_id(self, server_types_client, server_type_response): @pytest.mark.parametrize( "params", [{"name": "cx11", "page": 1, "per_page": 10}, {"name": ""}, {}] ) - def test_get_list(self, server_types_client, two_server_types_response, params): - server_types_client._client.request.return_value = two_server_types_response + def test_get_list( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + two_server_types_response, + params, + ): + request_mock.return_value = two_server_types_response + result = server_types_client.get_list(**params) - server_types_client._client.request.assert_called_with( - url="/server_types", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/server_types", + params=params, ) server_types = result.server_types - assert result.meta is None + assert result.meta is not None assert len(server_types) == 2 @@ -80,14 +118,23 @@ def test_get_list(self, server_types_client, two_server_types_response, params): assert server_types2.name == "cx21" @pytest.mark.parametrize("params", [{"name": "cx11"}]) - def test_get_all(self, server_types_client, two_server_types_response, params): - server_types_client._client.request.return_value = two_server_types_response + def test_get_all( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + two_server_types_response, + params, + ): + request_mock.return_value = two_server_types_response + server_types = server_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - server_types_client._client.request.assert_called_with( - url="/server_types", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/server_types", + params=params, ) assert len(server_types) == 2 @@ -103,14 +150,22 @@ def test_get_all(self, server_types_client, two_server_types_response, params): assert server_types2.id == 2 assert server_types2.name == "cx21" - def test_get_by_name(self, server_types_client, one_server_types_response): - server_types_client._client.request.return_value = one_server_types_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + one_server_types_response, + ): + request_mock.return_value = one_server_types_response + server_type = server_types_client.get_by_name("cx11") params = {"name": "cx11"} - server_types_client._client.request.assert_called_with( - url="/server_types", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/server_types", + params=params, ) assert server_type._client is server_types_client diff --git a/tests/unit/server_types/test_domain.py b/tests/unit/server_types/test_domain.py new file mode 100644 index 00000000..6d449061 --- /dev/null +++ b/tests/unit/server_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.server_types import ServerType + + +@pytest.mark.parametrize( + "value", + [ + (ServerType(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) 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 70823c30..3d3ab085 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -4,8 +4,8 @@ import pytest +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 @@ -26,13 +26,49 @@ ) from hcloud.volumes import BoundVolume, Volume +from ..conftest import BoundModelTestCase + + +class TestBoundServer(BoundModelTestCase): + methods = [ + BoundServer.update, + BoundServer.delete, + BoundServer.add_to_placement_group, + BoundServer.remove_from_placement_group, + BoundServer.attach_iso, + BoundServer.detach_iso, + BoundServer.attach_to_network, + BoundServer.detach_from_network, + BoundServer.change_alias_ips, + BoundServer.change_dns_ptr, + BoundServer.change_protection, + BoundServer.change_type, + BoundServer.create_image, + BoundServer.disable_backup, + BoundServer.enable_backup, + BoundServer.disable_rescue, + BoundServer.enable_rescue, + BoundServer.get_metrics, + BoundServer.power_off, + BoundServer.power_on, + BoundServer.reboot, + BoundServer.rebuild, + BoundServer.shutdown, + BoundServer.reset, + BoundServer.request_console, + BoundServer.reset_password, + ] -class TestBoundServer: @pytest.fixture() - def bound_server(self, hetzner_client): - return BoundServer(client=hetzner_client.servers, data=dict(id=14)) + def resource_client(self, client: Client): + return client.servers - def test_bound_server_init(self, response_full_server): + @pytest.fixture() + def bound_model(self, resource_client: ServersClient): + return BoundServer(resource_client, data=dict(id=14)) + + # pylint: disable=too-many-statements + def test_init(self, response_full_server): bound_server = BoundServer( client=mock.MagicMock(), data=response_full_server["server"] ) @@ -63,40 +99,33 @@ def test_bound_server_init(self, response_full_server): assert bound_server.public_net.floating_ips[0].id == 478 assert bound_server.public_net.floating_ips[0].complete is False - assert isinstance(bound_server.datacenter, BoundDatacenter) - assert ( - bound_server.datacenter._client == bound_server._client._client.datacenters - ) - assert bound_server.datacenter.id == 1 - assert bound_server.datacenter.complete is True - assert isinstance(bound_server.server_type, BoundServerType) assert ( bound_server.server_type._client - == bound_server._client._client.server_types + == bound_server._client._parent.server_types ) assert bound_server.server_type.id == 1 assert bound_server.server_type.complete is True assert len(bound_server.volumes) == 2 assert isinstance(bound_server.volumes[0], BoundVolume) - assert bound_server.volumes[0]._client == bound_server._client._client.volumes + assert bound_server.volumes[0]._client == bound_server._client._parent.volumes assert bound_server.volumes[0].id == 1 assert bound_server.volumes[0].complete is False assert isinstance(bound_server.volumes[1], BoundVolume) - assert bound_server.volumes[1]._client == bound_server._client._client.volumes + assert bound_server.volumes[1]._client == bound_server._client._parent.volumes assert bound_server.volumes[1].id == 2 assert bound_server.volumes[1].complete is False assert isinstance(bound_server.image, BoundImage) - assert bound_server.image._client == bound_server._client._client.images + assert bound_server.image._client == bound_server._client._parent.images assert bound_server.image.id == 4711 assert bound_server.image.name == "ubuntu-20.04" assert bound_server.image.complete is True assert isinstance(bound_server.iso, BoundIso) - assert bound_server.iso._client == bound_server._client._client.isos + assert bound_server.iso._client == bound_server._client._parent.isos assert bound_server.iso.id == 4711 assert bound_server.iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" assert bound_server.iso.complete is True @@ -105,7 +134,7 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.private_net[0], PrivateNet) assert ( bound_server.private_net[0].network._client - == bound_server._client._client.networks + == bound_server._client._parent.networks ) assert bound_server.private_net[0].ip == "10.1.1.5" assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" @@ -115,428 +144,31 @@ def test_bound_server_init(self, response_full_server): assert isinstance(bound_server.placement_group, BoundPlacementGroup) assert ( bound_server.placement_group._client - == bound_server._client._client.placement_groups + == bound_server._client._parent.placement_groups ) assert bound_server.placement_group.id == 897 assert bound_server.placement_group.name == "my Placement Group" assert bound_server.placement_group.complete is True - @pytest.mark.parametrize( - "params", - [ - { - "status": [Server.STATUS_RUNNING], - "sort": "status", - "page": 1, - "per_page": 10, - }, - {}, - ], - ) - def test_get_actions_list( - self, hetzner_client, bound_server, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - result = bound_server.get_actions_list(**params) - hetzner_client.request.assert_called_with( - url="/servers/14/actions", method="GET", params=params - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - @pytest.mark.parametrize( - "params", [{"status": [Server.STATUS_RUNNING], "sort": "status"}, {}] - ) - def test_get_actions( - self, hetzner_client, bound_server, response_get_actions, params - ): - hetzner_client.request.return_value = response_get_actions - actions = bound_server.get_actions(**params) - - params.update({"page": 1, "per_page": 50}) - - hetzner_client.request.assert_called_with( - url="/servers/14/actions", method="GET", params=params - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - def test_update(self, hetzner_client, bound_server, response_update_server): - hetzner_client.request.return_value = response_update_server - server = bound_server.update(name="new-name", labels={}) - hetzner_client.request.assert_called_with( - url="/servers/14", method="PUT", json={"name": "new-name", "labels": {}} - ) - - assert server.id == 14 - assert server.name == "new-name" - - def test_delete(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.delete() - hetzner_client.request.assert_called_with(url="/servers/14", method="DELETE") - - assert action.id == 1 - assert action.progress == 0 - - def test_get_metrics( - self, - hetzner_client, - bound_server: BoundServer, - response_get_metrics, - ): - hetzner_client.request.return_value = response_get_metrics - response = bound_server.get_metrics( - type=["cpu", "disk"], - start="2023-12-14T17:40:00+01:00", - end="2023-12-14T17:50:00+01:00", - ) - hetzner_client.request.assert_called_with( - url="/servers/14/metrics", - method="GET", - params={ - "type": "cpu,disk", - "start": "2023-12-14T17:40:00+01:00", - "end": "2023-12-14T17:50:00+01:00", - }, - ) - - assert "cpu" in response.metrics.time_series - assert "disk.0.iops.read" in response.metrics.time_series - assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 - - def test_power_off(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.power_off() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/poweroff", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_power_on(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.power_on() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/poweron", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reboot(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.reboot() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/reboot", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reset(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.reset() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/reset", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_shutdown(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.shutdown() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/shutdown", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_reset_password( - self, hetzner_client, bound_server, response_server_reset_password - ): - hetzner_client.request.return_value = response_server_reset_password - response = bound_server.reset_password() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/reset_password", method="POST" - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password == "YItygq1v3GYjjMomLaKc" - - def test_change_type(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/change_type", - method="POST", - json={"server_type": "cx11", "upgrade_disk": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_enable_rescue( - self, hetzner_client, bound_server, response_server_enable_rescue - ): - hetzner_client.request.return_value = response_server_enable_rescue - response = bound_server.enable_rescue(type="linux64") - hetzner_client.request.assert_called_with( - url="/servers/14/actions/enable_rescue", - method="POST", - json={"type": "linux64"}, - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password == "YItygq1v3GYjjMomLaKc" - - def test_disable_rescue(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.disable_rescue() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/disable_rescue", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_create_image( - self, hetzner_client, bound_server, response_server_create_image - ): - hetzner_client.request.return_value = response_server_create_image - response = bound_server.create_image(description="my image", type="snapshot") - hetzner_client.request.assert_called_with( - url="/servers/14/actions/create_image", - method="POST", - json={"description": "my image", "type": "snapshot"}, - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.image.description == "my image" - - def test_rebuild(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - response = bound_server.rebuild( - Image(name="ubuntu-20.04"), - return_response=True, - ) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/rebuild", - method="POST", - json={"image": "ubuntu-20.04"}, - ) - assert response.action.id == 1 - assert response.action.progress == 0 - assert response.root_password is None or isinstance(response.root_password, str) - - def test_enable_backup(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.enable_backup() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/enable_backup", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_disable_backup(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.disable_backup() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/disable_backup", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_attach_iso(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/attach_iso", - method="POST", - json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_detach_iso(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.detach_iso() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/detach_iso", method="POST" - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_dns_ptr(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.change_dns_ptr("1.2.3.4", "example.com") - hetzner_client.request.assert_called_with( - url="/servers/14/actions/change_dns_ptr", - method="POST", - json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_change_protection(self, hetzner_client, bound_server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_server.change_protection(True, True) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/change_protection", - method="POST", - json={"delete": True, "rebuild": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - def test_request_console( - self, hetzner_client, bound_server, response_server_request_console - ): - hetzner_client.request.return_value = response_server_request_console - response = bound_server.request_console() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/request_console", method="POST" - ) - - assert response.action.id == 1 - assert response.action.progress == 0 - assert ( - response.wss_url - == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" - ) - assert response.password == "9MQaTg2VAGI0FIpc10k3UpRXcHj2wQ6x" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_attach_to_network( - self, hetzner_client, bound_server, network, response_attach_to_network - ): - hetzner_client.request.return_value = response_attach_to_network - action = bound_server.attach_to_network( - network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] - ) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/attach_to_network", - method="POST", - json={ - "network": 4711, - "ip": "10.0.1.1", - "alias_ips": ["10.0.1.2", "10.0.1.3"], - }, - ) - - assert action.id == 1 - assert action.progress == 0 - assert action.command == "attach_to_network" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_detach_from_network( - self, hetzner_client, bound_server, network, response_detach_from_network - ): - hetzner_client.request.return_value = response_detach_from_network - action = bound_server.detach_from_network(network) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/detach_from_network", - method="POST", - json={"network": 4711}, - ) - - assert action.id == 1 - assert action.progress == 0 - assert action.command == "detach_from_network" - - @pytest.mark.parametrize( - "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] - ) - def test_change_alias_ips( - self, hetzner_client, bound_server, network, response_change_alias_ips - ): - hetzner_client.request.return_value = response_change_alias_ips - action = bound_server.change_alias_ips(network, ["10.0.1.2", "10.0.1.3"]) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/change_alias_ips", - method="POST", - json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, - ) - - assert action.id == 1 - assert action.progress == 0 - assert action.command == "change_alias_ips" +class TestServersClient: + @pytest.fixture() + def servers_client(self, client: Client): + return ServersClient(client) - @pytest.mark.parametrize( - "placement_group", - [PlacementGroup(id=897), BoundPlacementGroup(mock.MagicMock, dict(id=897))], - ) - def test_add_to_placement_group( + def test_get_by_id( self, - hetzner_client, - bound_server, - placement_group, - response_add_to_placement_group, - ): - hetzner_client.request.return_value = response_add_to_placement_group - action = bound_server.add_to_placement_group(placement_group) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/add_to_placement_group", - method="POST", - json={"placement_group": "897"}, - ) - - assert action.id == 13 - assert action.progress == 0 - assert action.command == "add_to_placement_group" - - def test_remove_from_placement_group( - self, hetzner_client, bound_server, response_remove_from_placement_group + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_server, ): - hetzner_client.request.return_value = response_remove_from_placement_group - action = bound_server.remove_from_placement_group() - hetzner_client.request.assert_called_with( - url="/servers/14/actions/remove_from_placement_group", method="POST" - ) - - assert action.id == 13 - assert action.progress == 100 - assert action.command == "remove_from_placement_group" + request_mock.return_value = response_simple_server - -class TestServersClient: - @pytest.fixture() - def servers_client(self): - return ServersClient(client=mock.MagicMock()) - - def test_get_by_id(self, servers_client, response_simple_server): - servers_client._client.request.return_value = response_simple_server bound_server = servers_client.get_by_id(1) - servers_client._client.request.assert_called_with( - url="/servers/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/servers/1", ) assert bound_server._client is servers_client assert bound_server.id == 1 @@ -550,15 +182,25 @@ def test_get_by_id(self, servers_client, response_simple_server): {}, ], ) - def test_get_list(self, servers_client, response_simple_servers, params): - servers_client._client.request.return_value = response_simple_servers + def test_get_list( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + params, + ): + request_mock.return_value = response_simple_servers + result = servers_client.get_list(**params) - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, ) bound_servers = result.servers - assert result.meta is None + assert result.meta is not None assert len(bound_servers) == 2 @@ -576,14 +218,23 @@ def test_get_list(self, servers_client, response_simple_servers, params): @pytest.mark.parametrize( "params", [{"name": "server1", "label_selector": "label1"}, {}] ) - def test_get_all(self, servers_client, response_simple_servers, params): - servers_client._client.request.return_value = response_simple_servers + def test_get_all( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + params, + ): + request_mock.return_value = response_simple_servers + bound_servers = servers_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, ) assert len(bound_servers) == 2 @@ -599,65 +250,46 @@ def test_get_all(self, servers_client, response_simple_servers, params): assert bound_server2.id == 2 assert bound_server2.name == "my-server2" - def test_get_by_name(self, servers_client, response_simple_servers): - servers_client._client.request.return_value = response_simple_servers + def test_get_by_name( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + ): + request_mock.return_value = response_simple_servers + bound_server = servers_client.get_by_name("my-server") params = {"name": "my-server"} - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params - ) - - assert bound_server._client is servers_client - assert bound_server.id == 1 - assert bound_server.name == "my-server" - - def test_create_with_datacenter( - self, servers_client, response_create_simple_server - ): - servers_client._client.request.return_value = response_create_simple_server - response = servers_client.create( - "my-server", - server_type=ServerType(name="cx11"), - image=Image(id=4711), - datacenter=Datacenter(id=1), - ) - servers_client._client.request.assert_called_with( + request_mock.assert_called_with( + method="GET", url="/servers", - method="POST", - json={ - "name": "my-server", - "server_type": "cx11", - "image": 4711, - "datacenter": 1, - "start_after_create": True, - }, + params=params, ) - 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._client.actions - assert bound_action.id == 1 - assert bound_action.command == "create_server" + def test_create_with_location( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server - def test_create_with_location(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(name="ubuntu-20.04"), location=Location(name="fsn1"), ) - servers_client._client.request.assert_called_with( - url="/servers", + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -675,12 +307,18 @@ def test_create_with_location(self, servers_client, response_create_simple_serve assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" - def test_create_with_volumes(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_volumes( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server + volumes = [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -689,9 +327,10 @@ def test_create_with_volumes(self, servers_client, response_create_simple_server volumes=volumes, start_after_create=False, ) - servers_client._client.request.assert_called_with( - url="/servers", + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -713,14 +352,20 @@ def test_create_with_volumes(self, servers_client, response_create_simple_server assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" assert next_actions[0].id == 13 - def test_create_with_networks(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_networks( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server + networks = [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -729,9 +374,10 @@ def test_create_with_networks(self, servers_client, response_create_simple_serve networks=networks, start_after_create=False, ) - servers_client._client.request.assert_called_with( - url="/servers", + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -753,14 +399,20 @@ def test_create_with_networks(self, servers_client, response_create_simple_serve assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" assert next_actions[0].id == 13 - def test_create_with_firewalls(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + def test_create_with_firewalls( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server + firewalls = [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=2))] response = servers_client.create( "my-server", @@ -769,9 +421,10 @@ def test_create_with_firewalls(self, servers_client, response_create_simple_serv firewalls=firewalls, start_after_create=False, ) - servers_client._client.request.assert_called_with( - url="/servers", + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -793,16 +446,20 @@ def test_create_with_firewalls(self, servers_client, response_create_simple_serv assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" assert next_actions[0].id == 13 def test_create_with_placement_group( - self, servers_client, response_create_simple_server + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, ): - servers_client._client.request.return_value = response_create_simple_server + request_mock.return_value = response_create_simple_server + placement_group = PlacementGroup(id=1) response = servers_client.create( "my-server", @@ -812,9 +469,9 @@ def test_create_with_placement_group( placement_group=placement_group, ) - servers_client._client.request.assert_called_with( - url="/servers", + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -836,7 +493,7 @@ def test_create_with_placement_group( assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._client == servers_client._parent.actions assert bound_action.id == 1 assert bound_action.command == "create_server" @@ -845,31 +502,21 @@ def test_create_with_placement_group( @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_get_actions_list(self, servers_client, server, response_get_actions): - servers_client._client.request.return_value = response_get_actions - result = servers_client.get_actions_list(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions", method="GET", params={} - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == servers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" + def test_update( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_update_server, + ): + request_mock.return_value = response_update_server - @pytest.mark.parametrize( - "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] - ) - def test_update(self, servers_client, server, response_update_server): - servers_client._client.request.return_value = response_update_server server = servers_client.update(server, name="new-name", labels={}) - servers_client._client.request.assert_called_with( - url="/servers/1", method="PUT", json={"name": "new-name", "labels": {}} + + request_mock.assert_called_with( + method="PUT", + url="/servers/1", + json={"name": "new-name", "labels": {}}, ) assert server.id == 14 @@ -878,11 +525,20 @@ def test_update(self, servers_client, server, response_update_server): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.delete(server) - servers_client._client.request.assert_called_with( - url="/servers/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/servers/1", ) assert action.id == 1 @@ -891,11 +547,20 @@ def test_delete(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_power_off(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_power_off( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.power_off(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/poweroff", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/poweroff", ) assert action.id == 1 @@ -904,11 +569,20 @@ def test_power_off(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_power_on(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_power_on( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.power_on(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/poweron", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/poweron", ) assert action.id == 1 @@ -917,11 +591,20 @@ def test_power_on(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_reboot(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_reboot( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.reboot(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/reboot", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/reboot", ) assert action.id == 1 @@ -930,11 +613,20 @@ def test_reboot(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_reset(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_reset( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.reset(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/reset", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/reset", ) assert action.id == 1 @@ -943,11 +635,20 @@ def test_reset(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_shutdown(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_shutdown( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.shutdown(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/shutdown", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/shutdown", ) assert action.id == 1 @@ -957,12 +658,19 @@ def test_shutdown(self, servers_client, server, generic_action): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_reset_password( - self, servers_client, server, response_server_reset_password + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_reset_password, ): - servers_client._client.request.return_value = response_server_reset_password + request_mock.return_value = response_server_reset_password + response = servers_client.reset_password(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/reset_password", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/reset_password", ) assert response.action.id == 1 @@ -973,15 +681,21 @@ def test_reset_password( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_change_type_with_server_type_name( - self, servers_client, server, generic_action + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, ): - servers_client._client.request.return_value = generic_action + request_mock.return_value = action_response + action = servers_client.change_type( server, ServerType(name="cx11"), upgrade_disk=True ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/change_type", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/change_type", json={"server_type": "cx11", "upgrade_disk": True}, ) @@ -992,13 +706,19 @@ def test_change_type_with_server_type_name( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_change_type_with_server_type_id( - self, servers_client, server, generic_action + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, ): - servers_client._client.request.return_value = generic_action + request_mock.return_value = action_response + action = servers_client.change_type(server, ServerType(id=1), upgrade_disk=True) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/change_type", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/change_type", json={"server_type": 1, "upgrade_disk": True}, ) @@ -1008,21 +728,35 @@ def test_change_type_with_server_type_id( @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_type_with_blank_server_type(self, servers_client, server): + def test_change_type_with_blank_server_type( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + ): with pytest.raises(ValueError) as e: servers_client.change_type(server, ServerType(), upgrade_disk=True) assert str(e.value) == "id or name must be set" - servers_client._client.request.assert_not_called() + + request_mock.assert_not_called() @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_enable_rescue(self, servers_client, server, response_server_enable_rescue): - servers_client._client.request.return_value = response_server_enable_rescue + def test_enable_rescue( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_enable_rescue, + ): + request_mock.return_value = response_server_enable_rescue + response = servers_client.enable_rescue(server, "linux64", [2323]) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/enable_rescue", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/enable_rescue", json={"type": "linux64", "ssh_keys": [2323]}, ) @@ -1033,11 +767,20 @@ def test_enable_rescue(self, servers_client, server, response_server_enable_resc @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_disable_rescue(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_disable_rescue( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.disable_rescue(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/disable_rescue", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/disable_rescue", ) assert action.id == 1 @@ -1046,14 +789,22 @@ def test_disable_rescue(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_create_image(self, servers_client, server, response_server_create_image): - servers_client._client.request.return_value = response_server_create_image + def test_create_image( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_create_image, + ): + request_mock.return_value = response_server_create_image + response = servers_client.create_image( server, description="my image", type="snapshot", labels={"key": "value"} ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/create_image", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/create_image", json={ "description": "my image", "type": "snapshot", @@ -1068,16 +819,24 @@ def test_create_image(self, servers_client, server, response_server_create_image @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_rebuild(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_rebuild( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + response = servers_client.rebuild( server, Image(name="ubuntu-20.04"), return_response=True, ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/rebuild", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/rebuild", json={"image": "ubuntu-20.04"}, ) @@ -1085,19 +844,23 @@ def test_rebuild(self, servers_client, server, generic_action): assert response.action.progress == 0 assert response.root_password is None or isinstance(response.root_password, str) - def test_rebuild_return_response_deprecation(self, servers_client, generic_action): - servers_client._client.request.return_value = generic_action - with pytest.deprecated_call(): - servers_client.rebuild(Server(id=1), Image(name="ubuntu-20.04")) - @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_enable_backup(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_enable_backup( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.enable_backup(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/enable_backup", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/enable_backup", ) assert action.id == 1 @@ -1106,11 +869,20 @@ def test_enable_backup(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_disable_backup(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_disable_backup( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.disable_backup(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/disable_backup", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/disable_backup", ) assert action.id == 1 @@ -1119,14 +891,22 @@ def test_disable_backup(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_attach_iso(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_attach_iso( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.attach_iso( server, Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1") ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/attach_iso", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/attach_iso", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, ) @@ -1136,11 +916,20 @@ def test_attach_iso(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_detach_iso(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_detach_iso( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.detach_iso(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/detach_iso", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/detach_iso", ) assert action.id == 1 @@ -1149,12 +938,20 @@ def test_detach_iso(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_dns_ptr(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.change_dns_ptr(server, "1.2.3.4", "example.com") - servers_client._client.request.assert_called_with( - url="/servers/1/actions/change_dns_ptr", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/change_dns_ptr", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, ) @@ -1164,12 +961,20 @@ def test_change_dns_ptr(self, servers_client, server, generic_action): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + action = servers_client.change_protection(server, True, True) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/change_protection", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/change_protection", json={"delete": True, "rebuild": True}, ) @@ -1180,12 +985,19 @@ def test_change_protection(self, servers_client, server, generic_action): "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) def test_request_console( - self, servers_client, server, response_server_request_console + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_server_request_console, ): - servers_client._client.request.return_value = response_server_request_console + request_mock.return_value = response_server_request_console + response = servers_client.request_console(server) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/request_console", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/request_console", ) assert response.action.id == 1 @@ -1203,15 +1015,22 @@ def test_request_console( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_attach_to_network( - self, servers_client, server, network, response_attach_to_network + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_attach_to_network, ): - servers_client._client.request.return_value = response_attach_to_network + request_mock.return_value = response_attach_to_network + action = servers_client.attach_to_network( server, network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/attach_to_network", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/attach_to_network", json={ "network": 4711, "ip": "10.0.1.1", @@ -1230,13 +1049,20 @@ def test_attach_to_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_detach_from_network( - self, servers_client, server, network, response_detach_from_network + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_detach_from_network, ): - servers_client._client.request.return_value = response_detach_from_network + request_mock.return_value = response_detach_from_network + action = servers_client.detach_from_network(server, network) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/detach_from_network", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/detach_from_network", json={"network": 4711}, ) @@ -1251,15 +1077,22 @@ def test_detach_from_network( "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] ) def test_change_alias_ips( - self, servers_client, server, network, response_change_alias_ips + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_change_alias_ips, ): - servers_client._client.request.return_value = response_change_alias_ips + request_mock.return_value = response_change_alias_ips + action = servers_client.change_alias_ips( server, network, ["10.0.1.2", "10.0.1.3"] ) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/change_alias_ips", + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/change_alias_ips", json={"network": 4711, "alias_ips": ["10.0.1.2", "10.0.1.3"]}, ) @@ -1267,52 +1100,83 @@ def test_change_alias_ips( assert action.progress == 0 assert action.command == "change_alias_ips" - def test_actions_get_by_id(self, servers_client, response_get_actions): - servers_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = servers_client.actions.get_by_id(13) + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "placement_group", + [PlacementGroup(id=897), BoundPlacementGroup(mock.MagicMock, dict(id=897))], + ) + def test_add_to_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + placement_group, + response_add_to_placement_group, + ): + request_mock.return_value = response_add_to_placement_group + + action = servers_client.add_to_placement_group(server, placement_group) - servers_client._client.request.assert_called_with( - url="/servers/actions/13", method="GET" + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/add_to_placement_group", + json={"placement_group": 897}, ) - assert isinstance(action, BoundAction) - assert action._client == servers_client._client.actions assert action.id == 13 - assert action.command == "start_server" - def test_actions_get_list(self, servers_client, response_get_actions): - servers_client._client.request.return_value = response_get_actions - result = servers_client.actions.get_list() + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_remove_from_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_remove_from_placement_group, + ): + request_mock.return_value = response_remove_from_placement_group - servers_client._client.request.assert_called_with( - url="/servers/actions", - method="GET", - params={}, + action = servers_client.remove_from_placement_group(server) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/remove_from_placement_group", ) - actions = result.actions - assert result.meta is None + assert action.id == 13 - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_get_metrics( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_get_metrics, + ): + request_mock.return_value = response_get_metrics - def test_actions_get_all(self, servers_client, response_get_actions): - servers_client._client.request.return_value = response_get_actions - actions = servers_client.actions.get_all() + response = servers_client.get_metrics( + server, + type=["cpu", "disk"], + start="2023-12-14T17:40:00+01:00", + end="2023-12-14T17:50:00+01:00", + ) - servers_client._client.request.assert_called_with( - url="/servers/actions", + request_mock.assert_called_with( method="GET", - params={"page": 1, "per_page": 50}, + url="/servers/1/metrics", + params={ + "type": "cpu,disk", + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + }, ) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == servers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" + assert "cpu" in response.metrics.time_series + assert "disk.0.iops.read" in response.metrics.time_series + assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 diff --git a/tests/unit/servers/test_domain.py b/tests/unit/servers/test_domain.py index a4d10b63..2a80380c 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -2,8 +2,45 @@ import datetime from datetime import timezone +from unittest import mock -from hcloud.servers import Server +import pytest + +from hcloud.networks import Network +from hcloud.servers import ( + BoundServer, + IPv4Address, + IPv6Network, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServerCreatePublicNetwork, +) + + +@pytest.mark.parametrize( + "value", + [ + (Server(id=1),), + ( + PublicNetwork( + ipv4=None, + ipv6=None, + floating_ips=[], + primary_ipv4=None, + primary_ipv6=None, + ), + ), + (PublicNetworkFirewall(firewall=object(), status="pending"),), + (IPv4Address(ip="127.0.0.1", blocked=False, dns_ptr="example.com"),), + (IPv6Network("2001:0db8::0/64", blocked=False, dns_ptr="example.com"),), + (PrivateNet(network=object(), ip="127.0.0.1", alias_ips=[], mac_address=""),), + (ServerCreatePublicNetwork(),), + ], +) +def test_eq(value): + assert value.__eq__(value) class TestServer: @@ -12,3 +49,47 @@ def test_created_is_datetime(self): assert server.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + server = Server( + id=42, + private_net=[ + PrivateNet( + network=network1, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + PrivateNet( + network=network2, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + ], + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None + + server = BoundServer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + { + "network": 1, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + { + "network": 2, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + ], + }, + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 8a64a89f..bed07b54 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -4,15 +4,27 @@ import pytest +from hcloud import Client from hcloud.ssh_keys import BoundSSHKey, SSHKey, SSHKeysClient +from ..conftest import BoundModelTestCase + + +class TestBoundSSHKey(BoundModelTestCase): + methods = [ + BoundSSHKey.update, + BoundSSHKey.delete, + ] -class TestBoundSSHKey: @pytest.fixture() - def bound_ssh_key(self, hetzner_client): - return BoundSSHKey(client=hetzner_client.ssh_keys, data=dict(id=14)) + def resource_client(self, client: Client) -> SSHKeysClient: + return client.ssh_keys - def test_bound_ssh_key_init(self, ssh_key_response): + @pytest.fixture() + def bound_model(self, resource_client: SSHKeysClient) -> BoundSSHKey: + return BoundSSHKey(resource_client, data=dict(id=14)) + + def test_init(self, ssh_key_response): bound_ssh_key = BoundSSHKey( client=mock.MagicMock(), data=ssh_key_response["ssh_key"] ) @@ -25,34 +37,25 @@ def test_bound_ssh_key_init(self, ssh_key_response): ) assert bound_ssh_key.public_key == "ssh-rsa AAAjjk76kgf...Xt" - def test_update(self, hetzner_client, bound_ssh_key, response_update_ssh_key): - hetzner_client.request.return_value = response_update_ssh_key - ssh_key = bound_ssh_key.update(name="New name") - hetzner_client.request.assert_called_with( - url="/ssh_keys/14", method="PUT", json={"name": "New name"} - ) - - assert ssh_key.id == 2323 - assert ssh_key.name == "New name" - - def test_delete(self, hetzner_client, bound_ssh_key, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_ssh_key.delete() - hetzner_client.request.assert_called_with(url="/ssh_keys/14", method="DELETE") - - assert delete_success is True - class TestSSHKeysClient: @pytest.fixture() - def ssh_keys_client(self): - return SSHKeysClient(client=mock.MagicMock()) + def ssh_keys_client(self, client: Client): + return SSHKeysClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key_response, + ): + request_mock.return_value = ssh_key_response - def test_get_by_id(self, ssh_keys_client, ssh_key_response): - ssh_keys_client._client.request.return_value = ssh_key_response ssh_key = ssh_keys_client.get_by_id(1) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys/1", ) assert ssh_key._client is ssh_keys_client assert ssh_key.id == 2323 @@ -72,11 +75,21 @@ def test_get_by_id(self, ssh_keys_client, ssh_key_response): {}, ], ) - def test_get_list(self, ssh_keys_client, two_ssh_keys_response, params): - ssh_keys_client._client.request.return_value = two_ssh_keys_response + def test_get_list( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + two_ssh_keys_response, + params, + ): + request_mock.return_value = two_ssh_keys_response + result = ssh_keys_client.get_list(**params) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, ) ssh_keys = result.ssh_keys @@ -96,13 +109,23 @@ def test_get_list(self, ssh_keys_client, two_ssh_keys_response, params): @pytest.mark.parametrize( "params", [{"name": "My ssh key", "label_selector": "label1"}, {}] ) - def test_get_all(self, ssh_keys_client, two_ssh_keys_response, params): - ssh_keys_client._client.request.return_value = two_ssh_keys_response + def test_get_all( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + two_ssh_keys_response, + params, + ): + request_mock.return_value = two_ssh_keys_response + ssh_keys = ssh_keys_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, ) assert len(ssh_keys) == 2 @@ -118,42 +141,67 @@ def test_get_all(self, ssh_keys_client, two_ssh_keys_response, params): assert ssh_keys2.id == 2324 assert ssh_keys2.name == "SSH-Key" - def test_get_by_name(self, ssh_keys_client, one_ssh_keys_response): - ssh_keys_client._client.request.return_value = one_ssh_keys_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + one_ssh_keys_response, + ): + request_mock.return_value = one_ssh_keys_response + ssh_keys = ssh_keys_client.get_by_name("SSH-Key") params = {"name": "SSH-Key"} - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, ) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 assert ssh_keys.name == "SSH-Key" - def test_get_by_fingerprint(self, ssh_keys_client, one_ssh_keys_response): - ssh_keys_client._client.request.return_value = one_ssh_keys_response + def test_get_by_fingerprint( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + one_ssh_keys_response, + ): + request_mock.return_value = one_ssh_keys_response + ssh_keys = ssh_keys_client.get_by_fingerprint( "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" ) params = {"fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f"} - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, ) assert ssh_keys._client is ssh_keys_client assert ssh_keys.id == 2323 assert ssh_keys.name == "SSH-Key" - def test_create(self, ssh_keys_client, ssh_key_response): - ssh_keys_client._client.request.return_value = ssh_key_response + def test_create( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key_response, + ): + request_mock.return_value = ssh_key_response + ssh_key = ssh_keys_client.create( name="My ssh key", public_key="ssh-rsa AAAjjk76kgf...Xt" ) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys", + + request_mock.assert_called_with( method="POST", + url="/ssh_keys", json={"name": "My ssh key", "public_key": "ssh-rsa AAAjjk76kgf...Xt"}, ) @@ -163,11 +211,21 @@ def test_create(self, ssh_keys_client, ssh_key_response): @pytest.mark.parametrize( "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] ) - def test_update(self, ssh_keys_client, ssh_key, response_update_ssh_key): - ssh_keys_client._client.request.return_value = response_update_ssh_key + def test_update( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key, + response_update_ssh_key, + ): + request_mock.return_value = response_update_ssh_key + ssh_key = ssh_keys_client.update(ssh_key, name="New name") - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys/1", method="PUT", json={"name": "New name"} + + request_mock.assert_called_with( + method="PUT", + url="/ssh_keys/1", + json={"name": "New name"}, ) assert ssh_key.id == 2323 @@ -176,11 +234,20 @@ def test_update(self, ssh_keys_client, ssh_key, response_update_ssh_key): @pytest.mark.parametrize( "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, ssh_keys_client, ssh_key, generic_action): - ssh_keys_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + ssh_keys_client: SSHKeysClient, + ssh_key, + action_response, + ): + request_mock.return_value = action_response + delete_success = ssh_keys_client.delete(ssh_key) - ssh_keys_client._client.request.assert_called_with( - url="/ssh_keys/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/ssh_keys/1", ) assert delete_success is True diff --git a/tests/unit/ssh_keys/test_domain.py b/tests/unit/ssh_keys/test_domain.py index edcf7526..9521f3a5 100644 --- a/tests/unit/ssh_keys/test_domain.py +++ b/tests/unit/ssh_keys/test_domain.py @@ -3,12 +3,24 @@ import datetime from datetime import timezone +import pytest + from hcloud.ssh_keys import SSHKey +@pytest.mark.parametrize( + "value", + [ + (SSHKey(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestSSHKey: def test_created_is_datetime(self): - sshKey = SSHKey(id=1, created="2016-01-30T23:50+00:00") - assert sshKey.created == datetime.datetime( + ssh_key = SSHKey(id=1, created="2016-01-30T23:50+00:00") + assert ssh_key.created == datetime.datetime( 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/storage_box_types/__init__.py b/tests/unit/storage_box_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_box_types/conftest.py b/tests/unit/storage_box_types/conftest.py new file mode 100644 index 00000000..e2717142 --- /dev/null +++ b/tests/unit/storage_box_types/conftest.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box_type1(): + return { + "id": 42, + "name": "bx11", + "description": "BX11", + "snapshot_limit": 10, + "automatic_snapshot_limit": 10, + "subaccounts_limit": 100, + "size": 1099511627776, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ], + "deprecation": { + "unavailable_after": "2023-09-01T00:00:00+00:00", + "announced": "2023-06-01T00:00:00+00:00", + }, + } + + +@pytest.fixture() +def storage_box_type2(): + return { + "id": 43, + "name": "bx21", + "description": "BX21", + "snapshot_limit": 20, + "automatic_snapshot_limit": 20, + "subaccounts_limit": 100, + "size": 5497558138880, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"net": "1.0000", "gross": "1.1900"}, + "price_monthly": {"net": "1.0000", "gross": "1.1900"}, + "setup_fee": {"net": "1.0000", "gross": "1.1900"}, + } + ], + "deprecation": None, + } diff --git a/tests/unit/storage_box_types/test_client.py b/tests/unit/storage_box_types/test_client.py new file mode 100644 index 00000000..ee63a0f1 --- /dev/null +++ b/tests/unit/storage_box_types/test_client.py @@ -0,0 +1,165 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.storage_box_types import ( + BoundStorageBoxType, + StorageBoxTypesClient, +) + + +def assert_bound_model( + o: BoundStorageBoxType, + client: StorageBoxTypesClient, +): + assert isinstance(o, BoundStorageBoxType) + assert o._client is client + assert o.id == 42 + assert o.name == "bx11" + + +class TestClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxTypesClient: + return client.storage_box_types + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_type": storage_box_type1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types/42", + ) + + assert_bound_model(result, resource_client) + assert result.description == "BX11" + assert result.snapshot_limit == 10 + assert result.automatic_snapshot_limit == 10 + assert result.subaccounts_limit == 100 + assert result.size == 1099511627776 + assert result.prices == [ + { + "location": "fsn1", + "price_hourly": {"gross": "0.0051", "net": "0.0051"}, + "price_monthly": {"gross": "3.2000", "net": "3.2000"}, + "setup_fee": {"gross": "0.0000", "net": "0.0000"}, + } + ] + assert result.deprecation.announced == isoparse("2023-06-01T00:00:00+00:00") + assert result.deprecation.unavailable_after == isoparse( + "2023-09-01T00:00:00+00:00" + ) + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11", "page": 1, "per_page": 10}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + url="/storage_box_types", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_box_types) == 2 + + result1 = result.storage_box_types[0] + result2 = result.storage_box_types[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + @pytest.mark.parametrize( + "params", + [ + {"name": "bx11"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + storage_box_type2, + params, + ): + request_mock.return_value = { + "storage_box_types": [storage_box_type1, storage_box_type2] + } + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + url="/storage_box_types", + method="GET", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "bx11" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "bx21" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxTypesClient, + storage_box_type1, + ): + request_mock.return_value = {"storage_box_types": [storage_box_type1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_box_types", + params=params, + ) + + assert_bound_model(result, resource_client) diff --git a/tests/unit/storage_box_types/test_domain.py b/tests/unit/storage_box_types/test_domain.py new file mode 100644 index 00000000..829c906a --- /dev/null +++ b/tests/unit/storage_box_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_box_types import StorageBoxType + + +@pytest.mark.parametrize( + "value", + [ + (StorageBoxType(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/storage_boxes/__init__.py b/tests/unit/storage_boxes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/storage_boxes/conftest.py b/tests/unit/storage_boxes/conftest.py new file mode 100644 index 00000000..190c53d0 --- /dev/null +++ b/tests/unit/storage_boxes/conftest.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def storage_box1(): + return { + "id": 42, + "name": "storage-box1", + "created": "2025-01-30T23:55:00+00:00", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 42, + "name": "bx11", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "reachable_externally": False, + "samba_enabled": False, + "ssh_enabled": False, + "webdav_enabled": False, + "zfs_enabled": False, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": { + "key": "value", + }, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def storage_box2(): + return { + "id": 43, + "name": "storage-box2", + "created": "2022-09-30T10:30:09.000Z", + "status": "active", + "system": "FSN1-BX355", + "server": "u1337.your-storagebox.de", + "username": "u12345", + "storage_box_type": { + "id": 1334, + "name": "bx21", + }, + "location": { + "id": 1, + "name": "fsn1", + }, + "access_settings": { + "webdav_enabled": False, + "zfs_enabled": False, + "samba_enabled": False, + "ssh_enabled": True, + "reachable_externally": True, + }, + "snapshot_plan": { + "max_snapshots": 20, + "minute": 0, + "hour": 7, + "day_of_week": 7, + "day_of_month": None, + }, + "stats": { + "size": 2342236717056, + "size_data": 2102612983808, + "size_snapshots": 239623733248, + }, + "labels": {}, + "protection": {"delete": False}, + } + + +@pytest.fixture() +def storage_box_snapshot1(): + return { + "id": 34, + "name": "storage-box-snapshot1", + "description": "", + "is_automatic": False, + "stats": { + "size": 394957594, + "size_filesystem": 3949572745, + }, + "labels": { + "key": "value", + }, + "created": "2025-11-10T19:16:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_snapshot2(): + return { + "id": 35, + "name": "storage-box-snapshot2", + "description": "", + "is_automatic": True, + "stats": { + "size": 0, + "size_filesystem": 0, + }, + "labels": {}, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount1(): + return { + "id": 45, + "username": "u42-sub1", + "server": "u42-sub1.your-storagebox.de", + "home_directory": "tmp/", + "description": "Required by foo", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": { + "key": "value", + }, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } + + +@pytest.fixture() +def storage_box_subaccount2(): + return { + "id": 46, + "username": "u42-sub2", + "server": "u42-sub2.your-storagebox.de", + "home_directory": "backup/", + "description": "", + "access_settings": { + "samba_enabled": False, + "ssh_enabled": True, + "webdav_enabled": False, + "reachable_externally": True, + "readonly": False, + }, + "labels": {}, + "created": "2025-11-10T19:18:57Z", + "storage_box": 42, + } diff --git a/tests/unit/storage_boxes/test_client.py b/tests/unit/storage_boxes/test_client.py new file mode 100644 index 00000000..53d3302f --- /dev/null +++ b/tests/unit/storage_boxes/test_client.py @@ -0,0 +1,1209 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.locations import Location +from hcloud.storage_box_types import StorageBoxType +from hcloud.storage_boxes import ( + BoundStorageBox, + BoundStorageBoxSnapshot, + BoundStorageBoxSubaccount, + StorageBox, + StorageBoxAccessSettings, + StorageBoxesClient, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, + StorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_storage_box( + o: BoundStorageBox, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBox) + assert o._client is resource_client + assert o.id == 42 + assert o.name == "storage-box1" + + +def assert_bound_storage_box_snapshot( + o: BoundStorageBoxSnapshot, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSnapshot) + assert o._client is resource_client + assert o.id == 34 + assert o.name == "storage-box-snapshot1" + + +def assert_bound_storage_box_subaccount( + o: BoundStorageBoxSubaccount, + resource_client: StorageBoxesClient, +): + assert isinstance(o, BoundStorageBoxSubaccount) + assert o._client is resource_client + assert o.id == 45 + assert o.username == "u42-sub1" + + +class TestBoundStorageBox(BoundModelTestCase): + methods = [ + BoundStorageBox.update, + BoundStorageBox.delete, + BoundStorageBox.get_folders, + BoundStorageBox.change_protection, + BoundStorageBox.change_type, + BoundStorageBox.disable_snapshot_plan, + BoundStorageBox.enable_snapshot_plan, + BoundStorageBox.reset_password, + BoundStorageBox.rollback_snapshot, + BoundStorageBox.update_access_settings, + # Snapshots + BoundStorageBox.create_snapshot, + BoundStorageBox.get_snapshot_all, + BoundStorageBox.get_snapshot_by_id, + BoundStorageBox.get_snapshot_by_name, + BoundStorageBox.get_snapshot_list, + # Subaccounts + BoundStorageBox.create_subaccount, + BoundStorageBox.get_subaccount_all, + BoundStorageBox.get_subaccount_by_id, + BoundStorageBox.get_subaccount_by_name, + BoundStorageBox.get_subaccount_by_username, + BoundStorageBox.get_subaccount_list, + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box1, + ) -> BoundStorageBox: + return BoundStorageBox(resource_client, data=storage_box1) + + def test_init(self, bound_model: BoundStorageBox, resource_client): + o = bound_model + + assert_bound_storage_box(o, resource_client) + + assert o.storage_box_type.id == 42 + assert o.storage_box_type.name == "bx11" + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.system == "FSN1-BX355" + assert o.server == "u1337.your-storagebox.de" + assert o.username == "u12345" + assert o.labels == {"key": "value"} + assert o.protection == {"delete": False} + assert o.snapshot_plan.max_snapshots == 20 + assert o.snapshot_plan.minute == 0 + assert o.snapshot_plan.hour == 7 + assert o.snapshot_plan.day_of_week == 7 + assert o.snapshot_plan.day_of_month is None + assert o.access_settings.reachable_externally is False + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is False + assert o.access_settings.webdav_enabled is False + assert o.access_settings.zfs_enabled is False + assert o.stats.size == 2342236717056 + assert o.stats.size_data == 2102612983808 + assert o.stats.size_snapshots == 239623733248 + assert o.status == "active" + assert o.created == isoparse("2025-01-30T23:55:00Z") + + +class TestBoundStorageBoxSnapshot(BoundModelTestCase): + methods = [ + (BoundStorageBoxSnapshot.update, {"client_method": "update_snapshot"}), + (BoundStorageBoxSnapshot.delete, {"client_method": "delete_snapshot"}), + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ) -> BoundStorageBoxSnapshot: + return BoundStorageBoxSnapshot(resource_client, data=storage_box_snapshot1) + + def test_init(self, bound_model: BoundStorageBoxSnapshot, resource_client): + o = bound_model + + assert_bound_storage_box_snapshot(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.description == "" + assert o.is_automatic is False + assert o.labels == {"key": "value"} + assert o.stats.size == 394957594 + assert o.stats.size_filesystem == 3949572745 + assert o.created == isoparse("2025-11-10T19:16:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + o = BoundStorageBoxSnapshot(resource_client, data={"id": 34, "storage_box": 42}) + + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert o.labels is not None + + +class TestBoundStorageBoxSubaccount(BoundModelTestCase): + methods = [ + ( + BoundStorageBoxSubaccount.update, + {"client_method": "update_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.delete, + {"client_method": "delete_subaccount"}, + ), + ( + BoundStorageBoxSubaccount.change_home_directory, + {"client_method": "change_subaccount_home_directory"}, + ), + ( + BoundStorageBoxSubaccount.reset_password, + {"client_method": "reset_subaccount_password"}, + ), + ( + BoundStorageBoxSubaccount.update_access_settings, + {"client_method": "update_subaccount_access_settings"}, + ), + ] + + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + @pytest.fixture() + def bound_model( + self, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ) -> BoundStorageBoxSubaccount: + return BoundStorageBoxSubaccount(resource_client, data=storage_box_subaccount1) + + def test_init(self, bound_model: BoundStorageBoxSubaccount, resource_client): + o = bound_model + + assert_bound_storage_box_subaccount(o, resource_client) + + assert isinstance(o.storage_box, BoundStorageBox) + assert o.storage_box.id == 42 + + assert o.username == "u42-sub1" + assert o.description == "Required by foo" + assert o.server == "u42-sub1.your-storagebox.de" + assert o.home_directory == "tmp/" + assert o.access_settings.reachable_externally is True + assert o.access_settings.samba_enabled is False + assert o.access_settings.ssh_enabled is True + assert o.access_settings.webdav_enabled is False + assert o.access_settings.readonly is False + assert o.labels == {"key": "value"} + assert o.created == isoparse("2025-11-10T19:18:57Z") + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + o = BoundStorageBoxSubaccount( + resource_client, data={"id": 45, "storage_box": 42} + ) + + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert o.labels is not None + + +class TestStorageBoxClient: + @pytest.fixture() + def resource_client(self, client: Client) -> StorageBoxesClient: + return client.storage_boxes + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_box": storage_box1} + + result = resource_client.get_by_id(42) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42", + ) + + assert_bound_storage_box(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"page": 1, "per_page": 10}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.storage_boxes) == 2 + + result1 = result.storage_boxes[0] + result2 = result.storage_boxes[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "storage-box2" + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + storage_box2, + params, + ): + request_mock.return_value = {"storage_boxes": [storage_box1, storage_box2]} + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + url="/storage_boxes", + method="GET", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 42 + assert result1.name == "storage-box1" + + assert result2._client is resource_client + assert result2.id == 43 + assert result2.name == "storage-box2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = {"storage_boxes": [storage_box1]} + + result = resource_client.get_by_name("bx11") + + params = {"name": "bx11"} + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes", + params=params, + ) + + assert_bound_storage_box(result, resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + action1_running, + ): + request_mock.return_value = { + "storage_box": storage_box1, + "action": action1_running, + } + + result = resource_client.create( + name="storage-box1", + password="secret-password", + location=Location(name="fsn1"), + storage_box_type=StorageBoxType(name="bx11"), + ssh_keys=[], + access_settings=StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes", + json={ + "name": "storage-box1", + "password": "secret-password", + "location": "fsn1", + "storage_box_type": "bx11", + "ssh_keys": [], + "access_settings": { + "reachable_externally": True, + "samba_enabled": False, + "ssh_enabled": True, + }, + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box(result.storage_box, resource_client) + + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box1, + ): + request_mock.return_value = { + "storage_box": storage_box1, + } + + result = resource_client.update( + StorageBox(id=42), + name="name", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42", + json={ + "name": "name", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box(result, resource_client) + + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete(StorageBox(id=42)) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {"path": "dir1/path"}, + {}, + ], + ) + def test_get_folders( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + params, + ): + request_mock.return_value = { + "folders": ["dir1", "dir2"], + } + + result = resource_client.get_folders(StorageBox(id=42), **params) + + request_mock.assert_called_with( + method="GET", url="/storage_boxes/42/folders", params=params + ) + + assert result.folders == ["dir1", "dir2"] + + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(StorageBox(id=42), delete=True) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_change_type( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + StorageBox(id=42), + StorageBoxType(name="bx21"), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/change_type", + json={"storage_box_type": "bx21"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_password( + StorageBox(id=42), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/reset_password", + json={"password": "password"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_access_settings( + StorageBox(id=42), + StorageBoxAccessSettings( + reachable_externally=True, + ssh_enabled=True, + webdav_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "webdav_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_rollback_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.rollback_snapshot( + StorageBox(id=42), + StorageBoxSnapshot(id=32), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/rollback_snapshot", + json={"snapshot": 32}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_disable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.disable_snapshot_plan( + StorageBox(id=42), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/disable_snapshot_plan", + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_enable_snapshot_plan( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.enable_snapshot_plan( + StorageBox(id=42), + StorageBoxSnapshotPlan( + max_snapshots=10, + hour=3, + minute=30, + day_of_week=None, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/actions/enable_snapshot_plan", + json={ + "max_snapshots": 10, + "hour": 3, + "minute": 30, + "day_of_week": None, + "day_of_month": None, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + # Snapshots + ########################################################################### + + def test_get_snapshot_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshot": storage_box_snapshot1} + + result = resource_client.get_snapshot_by_id(StorageBox(42), 34) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_snapshot_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.snapshots) == 2 + + result1 = result.snapshots[0] + result2 = result.snapshots[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"name": "storage-box-snapshot1"}, + {"is_automatic": True}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_snapshot_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + storage_box_snapshot2, + params, + ): + request_mock.return_value = { + "snapshots": [storage_box_snapshot1, storage_box_snapshot2] + } + + result = resource_client.get_snapshot_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/snapshots", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 34 + assert result1.name == "storage-box-snapshot1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 35 + assert result2.name == "storage-box-snapshot2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_snapshot_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = {"snapshots": [storage_box_snapshot1]} + + result = resource_client.get_snapshot_by_name( + StorageBox(42), "storage-box-snapshot1" + ) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/snapshots", + params={"name": "storage-box-snapshot1"}, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_create_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1: dict, + action1_running, + ): + request_mock.return_value = { + "snapshot": { + # Only a partial object is returned + key: storage_box_snapshot1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_snapshot( + StorageBox(42), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/snapshots", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.snapshot, BoundStorageBoxSnapshot) + assert result.snapshot._client is resource_client + assert result.snapshot.id == 34 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_snapshot1, + ): + request_mock.return_value = { + "snapshot": storage_box_snapshot1, + } + + result = resource_client.update_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/snapshots/34", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_snapshot(result, resource_client) + + def test_delete_snapshot( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_snapshot( + StorageBoxSnapshot(id=34, storage_box=StorageBox(42)) + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/snapshots/34", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + # Subaccounts + ########################################################################### + + def test_get_subaccount_by_id( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccount": storage_box_subaccount1} + + result = resource_client.get_subaccount_by_id(StorageBox(42), 45) + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_get_subaccount_by_name( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_name(StorageBox(42), "subaccount1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"name": "subaccount1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "subaccount1"}, + {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + # {"page": 1, "per_page": 10} # No pagination + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_subaccount_list( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_list(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert result.meta is not None + assert len(result.subaccounts) == 2 + + result1 = result.subaccounts[0] + result2 = result.subaccounts[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + @pytest.mark.parametrize( + "params", + [ + {"name": "subaccount1"}, + {"username": "u42-sub1"}, + {"label_selector": "key=value"}, + {"sort": ["id:asc"]}, + {}, + ], + ) + def test_get_subaccount_all( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + storage_box_subaccount2, + params, + ): + request_mock.return_value = { + "subaccounts": [storage_box_subaccount1, storage_box_subaccount2] + } + + result = resource_client.get_subaccount_all(StorageBox(42), **params) + + request_mock.assert_called_with( + url="/storage_boxes/42/subaccounts", + method="GET", + params=params, + ) + + assert len(result) == 2 + + result1 = result[0] + result2 = result[1] + + assert result1._client is resource_client + assert result1.id == 45 + assert result1.username == "u42-sub1" + assert isinstance(result1.storage_box, BoundStorageBox) + assert result1.storage_box.id == 42 + + assert result2._client is resource_client + assert result2.id == 46 + assert result2.username == "u42-sub2" + assert isinstance(result2.storage_box, BoundStorageBox) + assert result2.storage_box.id == 42 + + def test_get_subaccount_by_username( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = {"subaccounts": [storage_box_subaccount1]} + + result = resource_client.get_subaccount_by_username(StorageBox(42), "u42-sub1") + + request_mock.assert_called_with( + method="GET", + url="/storage_boxes/42/subaccounts", + params={"username": "u42-sub1"}, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_create_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1: dict, + action1_running, + ): + request_mock.return_value = { + "subaccount": { + # Only a partial object is returned + key: storage_box_subaccount1[key] + for key in ["id", "storage_box"] + }, + "action": action1_running, + } + + result = resource_client.create_subaccount( + StorageBox(42), + name="subaccount1", + home_directory="tmp", + password="secret", + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + readonly=False, + ), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts", + json={ + "name": "subaccount1", + "home_directory": "tmp", + "password": "secret", + "access_settings": { + "reachable_externally": True, + "ssh_enabled": True, + "readonly": False, + }, + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert isinstance(result.subaccount, BoundStorageBoxSubaccount) + assert result.subaccount._client is resource_client + assert result.subaccount.id == 45 + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_update_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + storage_box_subaccount1, + ): + request_mock.return_value = { + "subaccount": storage_box_subaccount1, + } + + result = resource_client.update_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + description="something", + labels={"key": "value"}, + ) + + request_mock.assert_called_with( + method="PUT", + url="/storage_boxes/42/subaccounts/45", + json={ + "description": "something", + "labels": {"key": "value"}, + }, + ) + + assert_bound_storage_box_subaccount(result, resource_client) + + def test_delete_subaccount( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action1_running, + ): + request_mock.return_value = { + "action": action1_running, + } + + result = resource_client.delete_subaccount( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + ) + + request_mock.assert_called_with( + method="DELETE", + url="/storage_boxes/42/subaccounts/45", + ) + + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_change_subaccount_home_directory( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_subaccount_home_directory( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + home_directory="path", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/change_home_directory", + json={ + "home_directory": "path", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_reset_subaccount_password( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.reset_subaccount_password( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + password="password", + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/reset_subaccount_password", + json={ + "password": "password", + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + def test_update_subaccount_access_settings( + self, + request_mock: mock.MagicMock, + resource_client: StorageBoxesClient, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.update_subaccount_access_settings( + StorageBoxSubaccount(id=45, storage_box=StorageBox(42)), + access_settings=StorageBoxSubaccountAccessSettings( + reachable_externally=True, + ssh_enabled=True, + samba_enabled=False, + ), + ) + + request_mock.assert_called_with( + method="POST", + url="/storage_boxes/42/subaccounts/45/actions/update_access_settings", + json={ + "reachable_externally": True, + "ssh_enabled": True, + "samba_enabled": False, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) diff --git a/tests/unit/storage_boxes/test_domain.py b/tests/unit/storage_boxes/test_domain.py new file mode 100644 index 00000000..eef73e06 --- /dev/null +++ b/tests/unit/storage_boxes/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.storage_boxes import StorageBox + + +@pytest.mark.parametrize( + "value", + [ + (StorageBox(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 36ac9288..460a25da 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,184 +1,331 @@ from __future__ import annotations -import json -from unittest.mock import MagicMock +from http import HTTPStatus +from json import dumps +from typing import Any +from unittest import mock import pytest import requests -from hcloud import APIException, Client +from hcloud import ( + APIException, + Client, + constant_backoff_function, + exponential_backoff_function, +) +from hcloud._client import ClientBase, _build_user_agent -class TestHetznerClient: +def test_exponential_backoff_function(): + backoff = exponential_backoff_function( + base=1.0, + multiplier=2, + cap=60.0, + ) + max_retries = 5 + + results = [backoff(i) for i in range(max_retries)] + assert sum(results) == 31.0 + assert results == [1.0, 2.0, 4.0, 8.0, 16.0] + + +def test_constant_backoff_function(): + backoff = constant_backoff_function(interval=1.0) + max_retries = 5 + + for i in range(max_retries): + assert backoff(i) == 1.0 + + +def test_build_user_agent(): + assert _build_user_agent(None, None) == "hcloud-python/0.0.0" + assert _build_user_agent("my-app", None) == "my-app hcloud-python/0.0.0" + assert _build_user_agent("my-app", "1.0.0") == "my-app/1.0.0 hcloud-python/0.0.0" + assert _build_user_agent(None, "1.0.0") == "hcloud-python/0.0.0" + + +class TestClient: @pytest.fixture() def client(self): - Client._version = "0.0.0" - client = Client(token="project_token") + return Client(token="TOKEN") - client._requests_session = MagicMock() - return client + def test_request(self, client: Client): + client._client.request = mock.MagicMock() + client.request(method="GET", url="/path") + client._client.request.assert_called_once_with("GET", "/path") - @pytest.fixture() - def response(self): - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - return response - @pytest.fixture() - def fail_response(self, response): - response.status_code = 422 - error = { - "code": "invalid_input", - "message": "invalid input in field 'broken_field': is too long", - "details": { - "fields": [{"name": "broken_field", "messages": ["is too long"]}] - }, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response +def make_response( + status: HTTPStatus, + *, + json: Any | None = None, + text: str | None = None, +) -> requests.Response: + response = requests.Response() + response.status_code = status.value + response.reason = status.phrase + + if json is not None: + response.headers["Content-type"] = "application/json" + response._content = dumps(json).encode("utf-8") + elif text is not None: + response.headers["Content-type"] = "text/plain" + response._content = text.encode("utf-8") + return response + +class TestBaseClient: @pytest.fixture() - def rate_limit_response(self, response): - response.status_code = 422 - error = { - "code": "rate_limit_exceeded", - "message": "limit of 10 requests per hour reached", - "details": {}, - } - response._content = json.dumps({"error": error}).encode("utf-8") - return response - - def test__get_user_agent(self, client): - user_agent = client._get_user_agent() - assert user_agent == "hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name(self, client): - client = Client(token="project_token", application_name="my-app") - user_agent = client._get_user_agent() - assert user_agent == "my-app hcloud-python/0.0.0" - - def test__get_user_agent_with_application_name_and_version(self, client): - client = Client( - token="project_token", - application_name="my-app", - application_version="1.0.0", + def client(self): + client = ClientBase( + token="TOKEN", + endpoint="https://api.hetzner.cloud/v1", ) - user_agent = client._get_user_agent() - assert user_agent == "my-app/1.0.0 hcloud-python/0.0.0" + client._session = mock.MagicMock() + return client - def test__get_headers(self, client): - headers = client._get_headers() - assert headers == { + def test_init(self, client: ClientBase): + assert client._user_agent == "hcloud-python/0.0.0" + assert client._headers == { "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", } + assert client._poll_interval_func(1) == 1.0 + assert client._retry_interval_func(1) == pytest.approx(1.5, rel=0.5) # Jitter - def test_request_library_mocked(self, client): - response = client.request("POST", "url", params={"1": 2}) - assert response.__class__.__name__ == "MagicMock" + @pytest.mark.parametrize( + ("exception", "expected"), + [ + ( + APIException(code="rate_limit_exceeded", message="Error", details=None), + True, + ), + ( + 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, + ), + ( + APIException(code=429, message="Too Many Requests", details=None), + False, + ), + ( + APIException(code=502, message="Bad Gateway", details=None), + True, + ), + ( + APIException(code=503, message="Service Unavailable", details=None), + False, + ), + ( + APIException(code=504, message="Gateway Timeout", details=None), + True, + ), + ], + ) + def test_retry_policy( + self, + client: ClientBase, + exception: APIException, + expected: bool, + ): + assert client._retry_policy(exception) == expected + + def test_request_200(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ) - def test_request_ok(self, client, response): - client._requests_session.request.return_value = response - response = client.request( - "POST", "/servers", params={"argument": "value"}, timeout=2 + result = client.request( + method="POST", + url="/path", + params={"argument": "value"}, + timeout=2, ) - client._requests_session.request.assert_called_once_with( + + client._session.request.assert_called_once_with( method="POST", - url="https://api.hetzner.cloud/v1/servers", + url="https://api.hetzner.cloud/v1/path", headers={ "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", }, params={"argument": "value"}, timeout=2, ) - assert response == {"result": "data"} - - def test_request_fails(self, client, fail_response): - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == "invalid_input" - assert error.message == "invalid input in field 'broken_field': is too long" - assert error.details["fields"][0]["name"] == "broken_field" - - def test_request_500(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "Internal Server Error" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "Internal Server Error" - - def test_request_broken_json_200(self, client, response): - content = b"{'key': 'value'" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 200 - assert error.message == "OK" - assert error.details["content"] == content - - def test_request_empty_content_200(self, client, response): - content = "" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - response = client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 + assert result == {"result": "data"} + + def test_request_200_empty_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="", + ) + + result = client.request(method="POST", url="/path") + assert result == {} + + def test_request_fail_200_invalid_json(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="{'key': 'value'", ) - assert response == "" - - def test_request_500_empty_content(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "" - assert str(error) == "Internal Server Error" - - def test_request_limit(self, client, rate_limit_response): - client._retry_wait_time = 0 - client._requests_session.request.return_value = rate_limit_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert client._requests_session.request.call_count == 5 - assert error.code == "rate_limit_exceeded" - assert error.message == "limit of 10 requests per hour reached" - - def test_request_limit_then_success(self, client, rate_limit_response): - client._retry_wait_time = 0 - response = requests.Response() - response.status_code = 200 - response._content = json.dumps({"result": "data"}).encode("utf-8") - client._requests_session.request.side_effect = [rate_limit_response, response] - - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 200 + assert exc.value.message == "OK" + assert exc.value.details["content"] == b"{'key': 'value'" + + def test_request_fail_422(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "invalid_input", + "message": "invalid input in field 'broken_field': is too long", + "details": { + "fields": [ + {"name": "broken_field", "messages": ["is too long"]} + ] + }, + } + }, ) - assert client._requests_session.request.call_count == 2 + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "invalid_input" + assert exc.value.message == "invalid input in field 'broken_field': is too long" + assert exc.value.details["fields"][0]["name"] == "broken_field" + + def test_request_fail_422_correlation_id(self, client: ClientBase): + response = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "service_error", + "message": "Something crashed", + } + }, + ) + response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" + client._session.request.return_value = response + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "service_error" + assert exc.value.message == "Something crashed" + assert exc.value.details is None + assert exc.value.correlation_id == "67ed842dc8bc8673" + assert str(exc.value) == "Something crashed (service_error, 67ed842dc8bc8673)" + + def test_request_fail_500(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + text="Internal Server Error", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] == b"Internal Server Error" + + def test_request_fail_500_no_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] is None + assert str(exc.value) == "Internal Server Error (500)" + + def test_request_fail_419(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.return_value = make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ) + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert client._session.request.call_count == 6 + assert exc.value.code == "rate_limit_exceeded" + assert exc.value.message == "limit of 3600 requests per hour reached" + + def test_request_fail_419_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} + + def test_request_fail_timeout(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + client._session.request.side_effect = requests.exceptions.Timeout("timeout") + + with pytest.raises(requests.exceptions.Timeout) as exc: + client.request(method="GET", url="/path") + + assert str(exc.value) == "timeout" + assert client._session.request.call_count == 6 + + def test_request_fail_timeout_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + requests.exceptions.Timeout("timeout"), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..c4546ec8 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import pytest + +from hcloud import ( + APIException, + HCloudException, +) +from hcloud.actions import Action, ActionFailedException, ActionTimeoutException + +running_action = Action( + id=12345, + command="action_command", + status=Action.STATUS_RUNNING, +) + +failed_action = Action( + id=12345, + command="action_command", + status=Action.STATUS_ERROR, + error={"code": "action_failed", "message": "Action failed"}, +) + + +@pytest.mark.parametrize( + ("exception", "expected"), + [ + ( + # Should never be raised by itself + HCloudException(), + "", + ), + ( + # Should never be raised by itself + HCloudException("A test error"), + "A test error", + ), + ( + APIException(code="conflict", message="API error message", details=None), + "API error message (conflict)", + ), + ( + APIException( + code="conflict", + message="API error message", + details=None, + correlation_id="fddea8fabd02fb21", + ), + "API error message (conflict, fddea8fabd02fb21)", + ), + ( + ActionFailedException(failed_action), + "The pending action failed: Action failed (action_failed, 12345)", + ), + ( + ActionTimeoutException(running_action), + "The pending action timed out (action_command, 12345)", + ), + ], +) +def test_exceptions(exception, expected): + assert str(exception) == expected diff --git a/tests/unit/test_hcloud.py b/tests/unit/test_hcloud.py deleted file mode 100644 index c6931d7d..00000000 --- a/tests/unit/test_hcloud.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import annotations - -import pytest - - -def test_deprecated_hcloud_hcloud_module(): - with pytest.deprecated_call(): - from hcloud.hcloud import Client # noqa diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index bb8bed00..dd86f22b 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -5,16 +5,31 @@ import pytest from dateutil.parser import isoparse -from hcloud.actions import BoundAction +from hcloud import Client from hcloud.locations import BoundLocation, Location from hcloud.servers import BoundServer, Server from hcloud.volumes import BoundVolume, Volume, VolumesClient +from ..conftest import BoundModelTestCase + + +class TestBoundVolume(BoundModelTestCase): + methods = [ + BoundVolume.update, + BoundVolume.delete, + BoundVolume.change_protection, + BoundVolume.attach, + BoundVolume.detach, + BoundVolume.resize, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.volumes -class TestBoundVolume: @pytest.fixture() - def bound_volume(self, hetzner_client): - return BoundVolume(client=hetzner_client.volumes, data=dict(id=14)) + def bound_model(self, resource_client): + return BoundVolume(resource_client, data=dict(id=14)) def test_bound_volume_init(self, volume_response): bound_volume = BoundVolume( @@ -41,107 +56,25 @@ def test_bound_volume_init(self, volume_response): assert bound_volume.location.latitude == 50.47612 assert bound_volume.location.longitude == 12.370071 - def test_get_actions(self, hetzner_client, bound_volume, response_get_actions): - hetzner_client.request.return_value = response_get_actions - actions = bound_volume.get_actions(sort="id") - hetzner_client.request.assert_called_with( - url="/volumes/14/actions", - method="GET", - params={"page": 1, "per_page": 50, "sort": "id"}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == hetzner_client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - - def test_update(self, hetzner_client, bound_volume, response_update_volume): - hetzner_client.request.return_value = response_update_volume - volume = bound_volume.update(name="new-name") - hetzner_client.request.assert_called_with( - url="/volumes/14", method="PUT", json={"name": "new-name"} - ) - - assert volume.id == 4711 - assert volume.name == "new-name" - - def test_delete(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_volume.delete() - hetzner_client.request.assert_called_with(url="/volumes/14", method="DELETE") - - assert delete_success is True - - def test_change_protection(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_volume.change_protection(True) - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/change_protection", - method="POST", - json={"delete": True}, - ) - - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_attach(self, hetzner_client, bound_volume, server, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_volume.attach(server) - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/attach", method="POST", json={"server": 1} - ) - assert action.id == 1 - assert action.progress == 0 - - @pytest.mark.parametrize( - "server", (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))) - ) - def test_attach_with_automount( - self, hetzner_client, bound_volume, server, generic_action - ): - hetzner_client.request.return_value = generic_action - action = bound_volume.attach(server, False) - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/attach", - method="POST", - json={"server": 1, "automount": False}, - ) - assert action.id == 1 - assert action.progress == 0 - - def test_detach(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_volume.detach() - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/detach", method="POST" - ) - assert action.id == 1 - assert action.progress == 0 - - def test_resize(self, hetzner_client, bound_volume, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_volume.resize(50) - hetzner_client.request.assert_called_with( - url="/volumes/14/actions/resize", method="POST", json={"size": 50} - ) - assert action.id == 1 - assert action.progress == 0 - class TestVolumesClient: @pytest.fixture() - def volumes_client(self): - return VolumesClient(client=mock.MagicMock()) + def volumes_client(self, client: Client): + return VolumesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume_response, + ): + request_mock.return_value = volume_response - def test_get_by_id(self, volumes_client, volume_response): - volumes_client._client.request.return_value = volume_response bound_volume = volumes_client.get_by_id(1) - volumes_client._client.request.assert_called_with( - url="/volumes/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/volumes/1", ) assert bound_volume._client is volumes_client assert bound_volume.id == 1 @@ -151,15 +84,25 @@ def test_get_by_id(self, volumes_client, volume_response): "params", [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], ) - def test_get_list(self, volumes_client, two_volumes_response, params): - volumes_client._client.request.return_value = two_volumes_response + def test_get_list( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + two_volumes_response, + params, + ): + request_mock.return_value = two_volumes_response + result = volumes_client.get_list(**params) - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, ) bound_volumes = result.volumes - assert result.meta is None + assert result.meta is not None assert len(bound_volumes) == 2 @@ -175,14 +118,23 @@ def test_get_list(self, volumes_client, two_volumes_response, params): assert bound_volume2.name == "vault-storage" @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) - def test_get_all(self, volumes_client, two_volumes_response, params): - volumes_client._client.request.return_value = two_volumes_response + def test_get_all( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + two_volumes_response, + params, + ): + request_mock.return_value = two_volumes_response + bound_volumes = volumes_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, ) assert len(bound_volumes) == 2 @@ -198,22 +150,36 @@ def test_get_all(self, volumes_client, two_volumes_response, params): assert bound_volume2.id == 2 assert bound_volume2.name == "vault-storage" - def test_get_by_name(self, volumes_client, one_volumes_response): - volumes_client._client.request.return_value = one_volumes_response + def test_get_by_name( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + one_volumes_response, + ): + request_mock.return_value = one_volumes_response + bound_volume = volumes_client.get_by_name("database-storage") params = {"name": "database-storage"} - volumes_client._client.request.assert_called_with( - url="/volumes", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/volumes", + params=params, ) assert bound_volume._client is volumes_client assert bound_volume.id == 1 assert bound_volume.name == "database-storage" - def test_create_with_location(self, volumes_client, volume_create_response): - volumes_client._client.request.return_value = volume_create_response + def test_create_with_location( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume_create_response, + ): + request_mock.return_value = volume_create_response + response = volumes_client.create( 100, "database-storage", @@ -221,9 +187,10 @@ def test_create_with_location(self, volumes_client, volume_create_response): automount=False, format="xfs", ) - volumes_client._client.request.assert_called_with( - url="/volumes", + + request_mock.assert_called_with( method="POST", + url="/volumes", json={ "name": "database-storage", "size": 100, @@ -247,14 +214,26 @@ def test_create_with_location(self, volumes_client, volume_create_response): @pytest.mark.parametrize( "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] ) - def test_create_with_server(self, volumes_client, server, volume_create_response): - volumes_client._client.request.return_value = volume_create_response + def test_create_with_server( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + server, + volume_create_response, + ): + request_mock.return_value = volume_create_response + volumes_client.create( - 100, "database-storage", server=server, automount=False, format="xfs" + size=100, + name="database-storage", + server=server, + automount=False, + format="xfs", ) - volumes_client._client.request.assert_called_with( - url="/volumes", + + request_mock.assert_called_with( method="POST", + url="/volumes", json={ "name": "database-storage", "size": 100, @@ -264,53 +243,55 @@ def test_create_with_server(self, volumes_client, server, volume_create_response }, ) - def test_create_negative_size(self, volumes_client): + def test_create_negative_size( + self, + request_mock: mock.MagicMock, + volumes_client, + ): with pytest.raises(ValueError) as e: volumes_client.create( -100, "database-storage", location=Location(name="location") ) assert str(e.value) == "size must be greater than 0" - volumes_client._client.request.assert_not_called() + + request_mock.assert_not_called() @pytest.mark.parametrize( "location,server", [(None, None), ("location", Server(id=1))] ) def test_create_wrong_location_server_combination( - self, volumes_client, location, server + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + location, + server, ): with pytest.raises(ValueError) as e: volumes_client.create( 100, "database-storage", location=location, server=server ) assert str(e.value) == "only one of server or location must be provided" - volumes_client._client.request.assert_not_called() - - @pytest.mark.parametrize( - "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] - ) - def test_get_actions_list(self, volumes_client, volume, response_get_actions): - volumes_client._client.request.return_value = response_get_actions - result = volumes_client.get_actions_list(volume, sort="id") - volumes_client._client.request.assert_called_with( - url="/volumes/1/actions", method="GET", params={"sort": "id"} - ) - actions = result.actions - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - - assert actions[0]._client == volumes_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" + request_mock.assert_not_called() @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_update(self, volumes_client, volume, response_update_volume): - volumes_client._client.request.return_value = response_update_volume + def test_update( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + response_update_volume, + ): + request_mock.return_value = response_update_volume + volume = volumes_client.update(volume, name="new-name") - volumes_client._client.request.assert_called_with( - url="/volumes/1", method="PUT", json={"name": "new-name"} + + request_mock.assert_called_with( + method="PUT", + url="/volumes/1", + json={"name": "new-name"}, ) assert volume.id == 4711 @@ -319,12 +300,20 @@ def test_update(self, volumes_client, volume, response_update_volume): @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_change_protection(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_change_protection( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + action_response, + ): + request_mock.return_value = action_response + action = volumes_client.change_protection(volume, True) - volumes_client._client.request.assert_called_with( - url="/volumes/1/actions/change_protection", + + request_mock.assert_called_with( method="POST", + url="/volumes/1/actions/change_protection", json={"delete": True}, ) @@ -334,11 +323,20 @@ def test_change_protection(self, volumes_client, volume, generic_action): @pytest.mark.parametrize( "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] ) - def test_delete(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_delete( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + action_response, + ): + request_mock.return_value = action_response + delete_success = volumes_client.delete(volume) - volumes_client._client.request.assert_called_with( - url="/volumes/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/volumes/1", ) assert delete_success is True @@ -353,11 +351,22 @@ def test_delete(self, volumes_client, volume, generic_action): ), ], ) - def test_attach(self, volumes_client, server, volume, generic_action): - volumes_client._client.request.return_value = generic_action - action = volumes_client.attach(volume, server) - volumes_client._client.request.assert_called_with( - url="/volumes/12/actions/attach", method="POST", json={"server": 1} + def test_attach( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + server, + volume, + action_response, + ): + request_mock.return_value = action_response + + action = volumes_client.attach(volume, server, True) + + request_mock.assert_called_with( + method="POST", + url="/volumes/12/actions/attach", + json={"server": 1, "automount": True}, ) assert action.id == 1 assert action.progress == 0 @@ -365,11 +374,20 @@ def test_attach(self, volumes_client, server, volume, generic_action): @pytest.mark.parametrize( "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] ) - def test_detach(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_detach( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + action_response, + ): + request_mock.return_value = action_response + action = volumes_client.detach(volume) - volumes_client._client.request.assert_called_with( - url="/volumes/12/actions/detach", method="POST" + + request_mock.assert_called_with( + method="POST", + url="/volumes/12/actions/detach", ) assert action.id == 1 assert action.progress == 0 @@ -377,61 +395,21 @@ def test_detach(self, volumes_client, volume, generic_action): @pytest.mark.parametrize( "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] ) - def test_resize(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + def test_resize( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + action_response, + ): + request_mock.return_value = action_response + action = volumes_client.resize(volume, 50) - volumes_client._client.request.assert_called_with( - url="/volumes/12/actions/resize", method="POST", json={"size": 50} + + request_mock.assert_called_with( + method="POST", + url="/volumes/12/actions/resize", + json={"size": 50}, ) assert action.id == 1 assert action.progress == 0 - - def test_actions_get_by_id(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = { - "action": response_get_actions["actions"][0] - } - action = volumes_client.actions.get_by_id(13) - - volumes_client._client.request.assert_called_with( - url="/volumes/actions/13", method="GET" - ) - - assert isinstance(action, BoundAction) - assert action._client == volumes_client._client.actions - assert action.id == 13 - assert action.command == "attach_volume" - - def test_actions_get_list(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = response_get_actions - result = volumes_client.actions.get_list() - - volumes_client._client.request.assert_called_with( - url="/volumes/actions", - method="GET", - params={}, - ) - - actions = result.actions - assert result.meta is None - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - - def test_actions_get_all(self, volumes_client, response_get_actions): - volumes_client._client.request.return_value = response_get_actions - actions = volumes_client.actions.get_all() - - volumes_client._client.request.assert_called_with( - url="/volumes/actions", - method="GET", - params={"page": 1, "per_page": 50}, - ) - - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0]._client == volumes_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" diff --git a/tests/unit/volumes/test_domain.py b/tests/unit/volumes/test_domain.py index 3844f2de..50d54d43 100644 --- a/tests/unit/volumes/test_domain.py +++ b/tests/unit/volumes/test_domain.py @@ -3,9 +3,21 @@ import datetime from datetime import timezone +import pytest + from hcloud.volumes import Volume +@pytest.mark.parametrize( + "value", + [ + (Volume(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + class TestVolume: def test_created_is_datetime(self): volume = Volume(id=1, created="2016-01-30T23:50+00:00") diff --git a/tests/unit/zones/__init__.py b/tests/unit/zones/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/zones/conftest.py b/tests/unit/zones/conftest.py new file mode 100644 index 00000000..4984cf3b --- /dev/null +++ b/tests/unit/zones/conftest.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def zone1(): + return { + "id": 42, + "name": "example1.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "primary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone2(): + return { + "id": 43, + "name": "example2.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "secondary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone_rrset1(): + return { + "zone": 42, + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_rrset2(): + return { + "zone": 42, + "id": "blog/A", + "name": "blog", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_response(zone1): + return {"zone": zone1} + + +@pytest.fixture() +def zone_list_response(zone1, zone2): + return { + "zones": [zone1, zone2], + } + + +@pytest.fixture() +def zone_create_response(zone1, action1_running): + return { + "zone": zone1, + "action": action1_running, + } + + +@pytest.fixture() +def zone_rrset_response(zone_rrset1): + return { + "rrset": zone_rrset1, + } + + +@pytest.fixture() +def zone_rrset_list_response(zone_rrset1, zone_rrset2): + return { + "rrsets": [zone_rrset1, zone_rrset2], + } + + +@pytest.fixture() +def zone_rrset_create_response(zone_rrset1, action1_running): + return { + "rrset": zone_rrset1, + "action": action1_running, + } diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py new file mode 100644 index 00000000..a2f7d7c7 --- /dev/null +++ b/tests/unit/zones/test_client.py @@ -0,0 +1,1033 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.zones import ( + BoundZone, + BoundZoneRRSet, + Zone, + ZoneAuthoritativeNameservers, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZonesClient, +) + +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_zone1(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 42 + assert o.name == "example1.com" + + +def assert_bound_zone2(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 43 + assert o.name == "example2.com" + + +def assert_bound_zone_rrset1(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "www" + assert o.type == "A" + assert o.id == "www/A" + + +def assert_bound_zone_rrset2(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "blog" + assert o.type == "A" + assert o.id == "blog/A" + + +class TestZonesClient: + @pytest.fixture() + def resource_client(self, client: Client) -> ZonesClient: + return client.zones + + def test_get_using_id( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get(42) + + request_mock.assert_called_with( + method="GET", + url="/zones/42", + ) + + assert_bound_zone1(result, resource_client) + assert result.created == isoparse("2016-01-30T23:55:00+00:00") + assert result.mode == "primary" + assert result.ttl == 10800 + assert result.protection == {"delete": False} + assert result.labels == {"key": "value"} + assert result.primary_nameservers[0].address == "198.51.100.1" + assert result.primary_nameservers[0].port == 53 + assert result.primary_nameservers[1].address == "203.0.113.1" + assert result.primary_nameservers[1].port == 53 + + assert ( + result.authoritative_nameservers.assigned[0] == "hydrogen.ns.hetzner.com." + ) + assert ( + result.authoritative_nameservers.delegated[0] == "hydrogen.ns.hetzner.com." + ) + assert result.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert result.authoritative_nameservers.delegation_status == "valid" + + assert result.record_count == 0 + assert result.status == "ok" + assert result.registrar == "hetzner" + + def test_get_using_name( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get("example.com") + + request_mock.assert_called_with( + method="GET", + url="/zones/example.com", + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"mode": "primary"}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "example.com"}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + resp = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.zones) == 2 + assert_bound_zone1(resp.zones[0], resource_client) + assert_bound_zone2(resp.zones[1], resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone1(result[0], resource_client) + assert_bound_zone2(result[1], resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_create_response, + ): + request_mock.return_value = zone_create_response + + resp = resource_client.create( + name="example.com", + mode="primary", + ttl=3600, + labels={"key": "value"}, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + rrsets=[ZoneRRSet(name="www", type="A", records=[ZoneRecord("127.0.0.1")])], + ) + + request_mock.assert_called_with( + url="/zones", + method="POST", + json={ + "name": "example.com", + "mode": "primary", + "ttl": 3600, + "labels": {"key": "value"}, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ], + "rrsets": [ + {"name": "www", "type": "A", "records": [{"value": "127.0.0.1"}]} + ], + }, + ) + + assert_bound_zone1(resp.zone, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.update(zone, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + resp = resource_client.delete(zone) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_export_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + ): + request_mock.return_value = {"zonefile": "content"} + + resp = resource_client.export_zonefile(zone) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/zonefile", + ) + + assert resp.zonefile == "content" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_import_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.import_zonefile(zone, "content") + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/import_zonefile", + json={"zonefile": "content"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(zone, delete=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_primary_nameservers( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_primary_nameservers( + zone, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_primary_nameservers", + json={ + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_ttl(zone, 3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + # ============ RRSETS ============ + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_get_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_response, + ): + request_mock.return_value = zone_rrset_response + + result = resource_client.get_rrset(zone, "www", "A") + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_zone_rrset1(result, resource_client) + assert result.ttl == 3600 + assert result.protection == {"change": False} + assert result.labels == {"key": "value"} + assert result.records[0].value == "198.51.100.1" + assert result.records[0].comment == "web server" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"type": ["A"]}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "www"}, + {}, + ], + ) + def test_get_rrset_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + resp = resource_client.get_rrset_list(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.rrsets) == 2 + assert_bound_zone_rrset1(resp.rrsets[0], resource_client) + assert_bound_zone_rrset2(resp.rrsets[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_rrset_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + result = resource_client.get_rrset_all(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone_rrset1(result[0], resource_client) + assert_bound_zone_rrset2(result[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_create_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_create_response, + ): + request_mock.return_value = zone_rrset_create_response + + resp = resource_client.create_rrset( + zone, + name="www", + type="A", + ttl=3600, + labels={"key": "value"}, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets", + json={ + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + }, + ) + + assert_bound_zone_rrset1(resp.rrset, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_update_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + zone_rrset_response, + ): + rrset.zone = zone + + request_mock.return_value = zone_rrset_response + + result = resource_client.update_rrset(rrset, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone_rrset1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_delete_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + resp = resource_client.delete_rrset(rrset) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_protection(rrset, change=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json={"change": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_ttl(rrset, ttl=3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_add_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.add_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ttl=300, + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + "ttl": 300, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_update_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.update_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("198.51.100.2", ""), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/update_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "198.51.100.2", "comment": ""}, + {"value": "127.0.0.1"}, + ], + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_remove_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.remove_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_set_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.set_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + +class TestBoundZone(BoundModelTestCase): + methods = [ + BoundZone.update, + BoundZone.delete, + BoundZone.import_zonefile, + BoundZone.export_zonefile, + BoundZone.change_primary_nameservers, + BoundZone.change_ttl, + BoundZone.change_protection, + BoundZone.get_rrset_all, + BoundZone.get_rrset_list, + BoundZone.get_rrset, + BoundZone.create_rrset, + # With rrset sub resource + (BoundZone.update_rrset, {"sub_resource": True}), + (BoundZone.delete_rrset, {"sub_resource": True}), + (BoundZone.change_rrset_protection, {"sub_resource": True}), + (BoundZone.change_rrset_ttl, {"sub_resource": True}), + (BoundZone.add_rrset_records, {"sub_resource": True}), + (BoundZone.update_rrset_records, {"sub_resource": True}), + (BoundZone.remove_rrset_records, {"sub_resource": True}), + (BoundZone.set_rrset_records, {"sub_resource": True}), + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client: ZonesClient, zone1): + return BoundZone(resource_client, data=zone1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZone): + o = bound_model + + assert_bound_zone1(o, resource_client) + + assert o.id == 42 + assert o.name == "example1.com" + assert o.created == isoparse("2016-01-30T23:55:00+00:00") + assert o.mode == "primary" + assert o.ttl == 10800 + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert len(o.primary_nameservers) == 2 + + assert isinstance(o.primary_nameservers[0], ZonePrimaryNameserver) + assert o.primary_nameservers[0].address == "198.51.100.1" + assert o.primary_nameservers[0].port == 53 + assert isinstance(o.primary_nameservers[1], ZonePrimaryNameserver) + assert o.primary_nameservers[1].address == "203.0.113.1" + assert o.primary_nameservers[1].port == 53 + + assert isinstance(o.authoritative_nameservers, ZoneAuthoritativeNameservers) + assert o.authoritative_nameservers.assigned == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegated == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert o.authoritative_nameservers.delegation_status == "valid" + + assert o.record_count == 0 + assert o.status == "ok" + assert o.registrar == "hetzner" + + +class TestBoundZoneRRSet(BoundModelTestCase): + methods = [ + BoundZoneRRSet.update_rrset, + BoundZoneRRSet.delete_rrset, + BoundZoneRRSet.change_rrset_protection, + BoundZoneRRSet.change_rrset_ttl, + BoundZoneRRSet.add_rrset_records, + BoundZoneRRSet.update_rrset_records, + BoundZoneRRSet.remove_rrset_records, + BoundZoneRRSet.set_rrset_records, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client: ZonesClient, zone_rrset1): + return BoundZoneRRSet(resource_client, data=zone_rrset1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZoneRRSet): + o = bound_model + + assert_bound_zone_rrset1(o, resource_client) + + assert o.id == "www/A" + assert o.name == "www" + assert o.type == "A" + assert o.ttl == 3600 + assert o.labels == {"key": "value"} + assert o.protection == {"change": False} + assert len(o.records) == 1 + + assert isinstance(o.records[0], ZoneRecord) + assert o.records[0].value == "198.51.100.1" + assert o.records[0].comment == "web server" + + assert isinstance(o.zone, BoundZone) + assert o.zone.id == 42 + + def test_reload( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_rrset1, + ): + o = BoundZoneRRSet( + resource_client, + data={"id": "www/A", "zone": 42}, + complete=False, + ) + request_mock.return_value = {"rrset": zone_rrset1} + + o.reload() + + request_mock.assert_called_with( + method="GET", + url="/zones/42/rrsets/www/A", + ) + + assert o.labels is not None diff --git a/tox.ini b/tox.ini index cf17b6de..813895b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38, py39, py310, py311, py312 +envlist = py310, py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT @@ -10,8 +10,8 @@ commands = [gh-actions] python = - 3.8: py38 - 3.9: py39 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 + 3.14: py314