diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..09ecbe1d --- /dev/null +++ b/.flake8 @@ -0,0 +1,5 @@ +[flake8] +extend-ignore = + E501 +extend-exclude = + docs diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b376ed08..00000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,6 +0,0 @@ - \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/Bug_Report.md b/.github/ISSUE_TEMPLATE/Bug_Report.md index d3524671..12a716a1 100644 --- a/.github/ISSUE_TEMPLATE/Bug_Report.md +++ b/.github/ISSUE_TEMPLATE/Bug_Report.md @@ -1,14 +1,15 @@ --- name: 🐛 Bug Report about: If something isn't working as expected 🤔. - --- + ## Bug Report **Current Behavior** A clear and concise description of the behavior. **Input Code** + - REPL or Repo link if applicable: ```python @@ -19,11 +20,13 @@ your = "code" + "here" A clear and concise description of what you expected to happen (or code). **Environment** + - Python Version: [e.g. v2.6, v2.7, v3.0] - Hcloud-Python Version: [e.g. v0.1, v1.0] **Possible Solution** + **Additional context/Screenshots** -Add any other context about the problem here. If applicable, add screenshots to help explain. \ No newline at end of file +Add any other context about the problem here. If applicable, add screenshots to help explain. diff --git a/.github/ISSUE_TEMPLATE/Feature_Request.md b/.github/ISSUE_TEMPLATE/Feature_Request.md index 5bf95629..69579688 100644 --- a/.github/ISSUE_TEMPLATE/Feature_Request.md +++ b/.github/ISSUE_TEMPLATE/Feature_Request.md @@ -1,7 +1,6 @@ --- name: 🚀 Feature Request about: I have a suggestion (and may want to implement it 🙂)! - --- ## Feature Request @@ -17,4 +16,4 @@ A clear and concise description of any alternative solutions or features you've **Teachability, Documentation, Adoption, Migration Strategy** If you can, explain how users will be able to use this and possibly write out a version the docs. -Maybe a screenshot or design? \ No newline at end of file +Maybe a screenshot or design? diff --git a/.github/workflows/bot_stale.yml b/.github/workflows/bot_stale.yml deleted file mode 100644 index 0a3b97fc..00000000 --- a/.github/workflows/bot_stale.yml +++ /dev/null @@ -1,19 +0,0 @@ -name: "Close stale issues" -on: - schedule: - - cron: "30 12 * * *" -jobs: - stale: - runs-on: ubuntu-latest - steps: - - uses: actions/stale@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: 'This issue has been marked as stale because it has not had recent activity. The bot will close the issue if no further action occurs.' - exempt-issue-label: 'pinned' - stale-issue-label: 'stale' - stale-pr-message: 'This PR has been marked as stale because it has not had recent activity. The bot will close the PR if no further action occurs.' - exempt-pr-label: 'pinned' - stale-pr-label: 'stale' - days-before-stale: 90 - days-before-close: 30 diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml deleted file mode 100644 index 132aa94f..00000000 --- a/.github/workflows/code_style.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Code Style - -on: [push, pull_request] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: 3.7 - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/test.txt - pip install tox tox-gh-actions - sudo apt install build-essential - - name: flake8 with tox - run: tox -e flake8 - - name: black with tox - run: tox -e black diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml new file mode 100644 index 00000000..cf99d202 --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,39 @@ +name: Links + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "43 12 * * *" + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: actions/cache@v5 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - uses: lycheeverse/lychee-action@v2 + with: + fail: true + args: > + --root-dir ${{ github.workspace }} + --verbose + --no-progress + --cache + --max-cache-age 2d + --include-fragments + --exclude 'api.hetzner.cloud' + --exclude 'api.hetzner.com' + --exclude 'https://docs.hetzner.cloud/reference/cloud#' + --exclude 'https://docs.hetzner.cloud/reference/hetzner#' + --exclude 'codecov.io' + --exclude 'github.com' + '**/*.md' + '**/*.py' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..d0a893a0 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup python + uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Install dependencies + run: pip install pre-commit + + - name: Run pre-commit + run: pre-commit run --all-files --show-diff-on-failure + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup python + uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Install dependencies + run: make venv + + - name: Run lint + run: make lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cd05973..78b5aaed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,26 +1,56 @@ -name: Release Python Package +name: Release on: + push: + branches: [main] + pull_request: release: types: [created] jobs: - deploy: + build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python setup.py sdist bdist_wheel - twine upload dist/* + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: 3.x + + - name: Install dependencies + run: pip install build twine + + - name: Build + run: python3 -m build + + - name: Check + run: twine check --strict dist/* + + - name: Upload packages artifact + if: github.event_name == 'release' + uses: actions/upload-artifact@v6 + with: + name: python-packages + path: dist/ + + publish: + if: github.event_name == 'release' + needs: [build] + + environment: + name: pypi + url: https://pypi.org/p/hcloud + permissions: + id-token: write + + runs-on: ubuntu-latest + steps: + - name: Download packages artifact + uses: actions/download-artifact@v7 + with: + name: python-packages + path: dist/ + + - name: Publish packages to PyPI + uses: pypa/gh-action-pypi-publish@v1.13.0 diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml new file mode 100644 index 00000000..699110e4 --- /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@v0.7.1 + with: + token: ${{ secrets.HCLOUD_BOT_TOKEN }} + extra-files: | + setup.py + hcloud/_version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..379c1fc6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,42 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" + + name: Python ${{ matrix.python-version }} + steps: + - uses: actions/checkout@v6 + + - name: Setup python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install tox tox-gh-actions + + - name: Run tox + run: tox -- --cov --cov-report=xml + + - name: Upload coverage reports to Codecov + if: > + !startsWith(github.head_ref, 'renovate/') && + !startsWith(github.head_ref, 'releaser-pleaser--') + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml deleted file mode 100644 index ba7c79f1..00000000 --- a/.github/workflows/unit_test.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Unit Tests - -on: [push, pull_request] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [ 3.6, 3.7, 3.8, 3.9, "3.10.0-beta.1" ] - name: Python ${{ matrix.python-version }} - steps: - - uses: actions/checkout@v2 - - name: Setup python - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/test.txt - pip install tox tox-gh-actions - sudo apt install build-essential - - name: Test with tox - run: tox - env: - PLATFORM: ${{ matrix.platform }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c64963ff..f2cc23b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,44 +1,41 @@ -stages: -- test +include: + - project: cloud/integrations/ci + file: + - default.yml + - pre-commit.yml + - workflows/feature-branches.yml -.tests_template: &tests_template - before_script: - - pip install tox - - apk add build-base - stage: test - script: - tox - tags: - - hc-bladerunner +stages: + - test -python36: - <<: *tests_template - image: python:3.6-alpine - script: tox -e py36 +pre-commit: + extends: [.pre-commit] -python37: - <<: *tests_template - image: python:3.7-alpine - script: tox -e py37 +lint: + stage: test -python38: - <<: *tests_template - image: python:3.8-alpine - script: tox -e py38 + image: python:3.14-alpine + before_script: + - apk add make bash + - make venv + script: + - make lint -python39: - <<: *tests_template - image: python:3.9-alpine - script: tox -e py39 +test: + stage: test -python310: - <<: *tests_template - image: python:3.10-rc-alpine - script: tox -e py310 + parallel: + matrix: + - python_version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" -test-style: - <<: *tests_template - image: python:3.7-alpine + image: python:${python_version}-alpine + before_script: + - apk add make + - pip install tox script: - - tox -e flake8 - - tox -e black + - tox -e ${python_version} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..28d6d43d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,50 @@ +--- +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable + - id: check-symlinks + - id: destroyed-symlinks + + - id: check-json + - id: check-yaml + - id: check-toml + + - id: check-merge-conflict + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + files: \.(md|ya?ml|js|css)$ + + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + args: [--py310-plus] + + - repo: https://github.com/pycqa/isort + rev: 7.0.0 + hooks: + - id: isort + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 26.1.0 + hooks: + - id: black + + - repo: https://github.com/pycqa/flake8 + rev: 7.3.0 + hooks: + - id: flake8 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..dcda10d7 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,27 @@ +--- +# Read the Docs configuration file for Sphinx projects +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + fail_on_warning: true + +# Optionally build your docs in additional formats such as PDF and ePub +formats: [pdf, epub] + +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - method: pip + path: . + extra_requirements: [docs] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..a1b5c8f4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,819 @@ +# Changelog + +## [v2.16.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.16.0) + +### Storage Boxes support is now generally available + +The experimental phase for Storage Boxes is over, and Storage Boxes support is now generally available. + +### Features + +- **servers**: allow setting user_data for rebuild (#627) +- Storage Box support no longer experimental (#626) + +## [v2.15.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.15.0) + +### Features + +- 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 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 + +- support reloading sub resource bound models (#590) + +## [v2.11.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.11.0) + +### DNS API is now generally available + +The DNS API is now generally available, as well as support for features in this project that are related to the DNS API. + +To migrate existing zones to the new DNS API, see the [DNS migration guide](https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process/). + +See the [changelog](https://docs.hetzner.cloud/changelog#2025-11-10-dns-ga) for more details. + +### Features + +- DNS support is now generally available (#581) + +## [v2.10.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.10.0) + +### Features + +- **exp**: add zone format txt record helper (#578) +- add server and load balancer `private_net_for` helper method (#580) + +## [v2.9.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.9.0) + +### Features + +- 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 + +- add new `ip_range` param to load balancer `attach_to_network` (#562) +- add new `ip_range` param to server `attach_to_network` (#561) +- support the new DNS API (#568) + +### Bug Fixes + +- source_ips property is optional in firewall rule (#567) + +## [v2.7.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.7.0) + +[Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) now depend on [Locations](https://docs.hetzner.cloud/reference/cloud#locations). + +- We added a new `locations` property to the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The new property defines a list of supported [Locations](https://docs.hetzner.cloud/reference/cloud#locations) and additional per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) details such as deprecations information. + +- We deprecated the `deprecation` property from the [Server Types](https://docs.hetzner.cloud/reference/cloud#server-types) resource. The property will gradually be phased out as per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecations are being announced. Please use the new per [Locations](https://docs.hetzner.cloud/reference/cloud#locations) deprecation information instead. + +See our [changelog](https://docs.hetzner.cloud/changelog#2025-09-24-per-location-server-types) for more details. + +**Upgrading** + +```py +# Before +def validate_server_type(server_type: ServerType): + if server_type.deprecation is not None: + raise ValueError(f"server type {server_type.name} is deprecated") +``` + +```py +# After +def validate_server_type(server_type: ServerType, location: Location): + found = [o for o in server_type.locations if location.name == o.location.name] + if not found: + raise ValueError( + f"server type {server_type.name} is not supported in location {location.name}" + ) + + server_type_location = found[0] + + if server_type_location.deprecation is not None: + raise ValueError( + f"server type {server_type.name} is deprecated in location {location.name}" + ) +``` + +### Features + +- per location server types (#558) + +## [v2.6.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.6.0) + +### Features + +- add category property to server type (#549) + +### Bug Fixes + +- rename `ClientEntityBase` to `ResourceClientBase` (#532) + +## [v2.5.4](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.4) + +### Bug Fixes + +- typo in `LoadBalancerHealthCheckHttp` class name (#511) +- equality for some domain classes (#510) +- use valid license identifier (SPDX) (#514) + +## [v2.5.3](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.5.3) + +### Bug Fixes + +- 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 + +- 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 + +- 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) + +### Features + +- support python 3.13 ([#451](https://github.com/hetznercloud/hcloud-python/issues/451)) ([4a514c7](https://github.com/hetznercloud/hcloud-python/commit/4a514c7a1136a4a8c592c77120c5be36cd221b33)) + +### Bug Fixes + +- change floating ip labels type to `dict[str, str]` ([#444](https://github.com/hetznercloud/hcloud-python/issues/444)) ([1f6da4e](https://github.com/hetznercloud/hcloud-python/commit/1f6da4ef243321d3c6850b876f3c11fb1195edcf)) + +## [2.2.1](https://github.com/hetznercloud/hcloud-python/compare/v2.2.0...v2.2.1) (2024-08-19) + +### 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 + +- 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)) + +## [2.1.1](https://github.com/hetznercloud/hcloud-python/compare/v2.1.0...v2.1.1) (2024-07-30) + +### Bug Fixes + +- do not sleep before checking for the reloaded action status ([#426](https://github.com/hetznercloud/hcloud-python/issues/426)) ([3e0a85b](https://github.com/hetznercloud/hcloud-python/commit/3e0a85b487fc15941008e4d610243de3cb0396cb)) +- mark client retry backoff function as static ([#429](https://github.com/hetznercloud/hcloud-python/issues/429)) ([14ed130](https://github.com/hetznercloud/hcloud-python/commit/14ed130e989c68eacce2634c7983b200570de9c2)) + +### Documentation + +- add api changes note in changelog ([#424](https://github.com/hetznercloud/hcloud-python/issues/424)) ([5cbe188](https://github.com/hetznercloud/hcloud-python/commit/5cbe1889a21c686588d91ab90306d345ba5b84dd)) + +## [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 + +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. + +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). + +### 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 + +- `assignee_type` is required when creating a primary ip ([#409](https://github.com/hetznercloud/hcloud-python/issues/409)) ([bce5e94](https://github.com/hetznercloud/hcloud-python/commit/bce5e940e27f2c6d9d50016b5828c79aadfc4401)) +- clean unused arguments in the `Client.servers.rebuild` method ([#407](https://github.com/hetznercloud/hcloud-python/issues/407)) ([6d33c3c](https://github.com/hetznercloud/hcloud-python/commit/6d33c3cff5443686c7ed37eb8635e0461bb3b928)) +- details are optional in API errors ([#411](https://github.com/hetznercloud/hcloud-python/issues/411)) ([f1c6594](https://github.com/hetznercloud/hcloud-python/commit/f1c6594dee7088872f2375359ee259e4e93b31d2)) +- rename `trace_id` variable to `correlation_id` ([#408](https://github.com/hetznercloud/hcloud-python/issues/408)) ([66a0f54](https://github.com/hetznercloud/hcloud-python/commit/66a0f546998193f9078f70a4a2fb1fc11937c086)) + +## [2.0.0](https://github.com/hetznercloud/hcloud-python/compare/v1.35.0...v2.0.0) (2024-07-03) + +### ⚠ BREAKING CHANGES + +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) +- make `datacenter` argument optional when creating a primary ip ([#363](https://github.com/hetznercloud/hcloud-python/issues/363)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) + +### Features + +- add `trace_id` to API exceptions ([#404](https://github.com/hetznercloud/hcloud-python/issues/404)) ([8375261](https://github.com/hetznercloud/hcloud-python/commit/8375261da3b84d6fece97263c7bea40ad2a6cfcf)) +- allow using a custom poll_interval function ([#403](https://github.com/hetznercloud/hcloud-python/issues/403)) ([93eb56b](https://github.com/hetznercloud/hcloud-python/commit/93eb56ba4d1a69e175398bca42e723a7e8e46371)) +- make `Client.poll_interval` a private property ([#398](https://github.com/hetznercloud/hcloud-python/issues/398)) ([d5f24db](https://github.com/hetznercloud/hcloud-python/commit/d5f24db2816a0d00b8c7936e2a0290d2c4bb1e92)) +- make `Client.request` `tries` a private argument ([#399](https://github.com/hetznercloud/hcloud-python/issues/399)) ([428ea7e](https://github.com/hetznercloud/hcloud-python/commit/428ea7e3be03a16114f875146971db59aabaac2c)) +- move `hcloud.__version__.VERSION` to `hcloud.__version__` ([#397](https://github.com/hetznercloud/hcloud-python/issues/397)) ([4e3f638](https://github.com/hetznercloud/hcloud-python/commit/4e3f638862c9d260df98182c3f7858282049c26c)), closes [#234](https://github.com/hetznercloud/hcloud-python/issues/234) +- remove deprecated `hcloud.hcloud` module ([#401](https://github.com/hetznercloud/hcloud-python/issues/401)) ([db37e63](https://github.com/hetznercloud/hcloud-python/commit/db37e633ebbf73354d3b2f4858cf3eebf173bfbc)) +- remove deprecated `include_wildcard_architecture` argument in `IsosClient.get_list` and `IsosClient.get_all` ([#402](https://github.com/hetznercloud/hcloud-python/issues/402)) ([6b977e2](https://github.com/hetznercloud/hcloud-python/commit/6b977e2da5cec30110c32a91d572003e5b5c400a)) +- return empty dict on empty responses in `Client.request` ([#400](https://github.com/hetznercloud/hcloud-python/issues/400)) ([9f46adb](https://github.com/hetznercloud/hcloud-python/commit/9f46adb946eb2770ee4f3a4e87cfc1c8b9b33c28)) +- return full rebuild response in `Client.servers.rebuild` ([#406](https://github.com/hetznercloud/hcloud-python/issues/406)) ([1970d84](https://github.com/hetznercloud/hcloud-python/commit/1970d84bec2106c8c53d8e611b74d41eb5286e9b)) + +### 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 + +- 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 codecov/codecov-action action to v4 ([#359](https://github.com/hetznercloud/hcloud-python/issues/359)) ([a798979](https://github.com/hetznercloud/hcloud-python/commit/a79897977abe970181d19584e51448ff5976b5e2)) +- update dependency mypy to >=1.9,<1.10 ([#368](https://github.com/hetznercloud/hcloud-python/issues/368)) ([4b9328c](https://github.com/hetznercloud/hcloud-python/commit/4b9328ceae1e393ff55b3ca6f030cb5ac565be00)) +- update dependency pylint to >=3,<3.2 ([#364](https://github.com/hetznercloud/hcloud-python/issues/364)) ([d71d17f](https://github.com/hetznercloud/hcloud-python/commit/d71d17fd6f2968a8c19052753265ef7f514a8955)) +- update dependency pytest to >=8,<8.2 ([#366](https://github.com/hetznercloud/hcloud-python/issues/366)) ([8665dcf](https://github.com/hetznercloud/hcloud-python/commit/8665dcff335c755c1ff4d95621334a3f5e196d34)) +- update dependency pytest to v8 ([#357](https://github.com/hetznercloud/hcloud-python/issues/357)) ([f8f756f](https://github.com/hetznercloud/hcloud-python/commit/f8f756fe0a492e284bd2a700514c0ba38358b4a8)) +- update dependency pytest-cov to v5 ([#371](https://github.com/hetznercloud/hcloud-python/issues/371)) ([04a6a42](https://github.com/hetznercloud/hcloud-python/commit/04a6a42028606ed66657605d98b1f21545eb2e0d)) +- update dependency watchdog to v4 ([#360](https://github.com/hetznercloud/hcloud-python/issues/360)) ([cb8d383](https://github.com/hetznercloud/hcloud-python/commit/cb8d38396a8665506e3be64a09450343d7671586)) +- update pre-commit hook asottile/pyupgrade to v3.15.1 ([#362](https://github.com/hetznercloud/hcloud-python/issues/362)) ([dd2a521](https://github.com/hetznercloud/hcloud-python/commit/dd2a521eccec8e15b6d1d7fd843d866bf6ea5bcf)) +- update pre-commit hook asottile/pyupgrade to v3.15.2 ([3d02ad7](https://github.com/hetznercloud/hcloud-python/commit/3d02ad71e9200f5cc94b2d33eea62035edc1e33a)) +- update pre-commit hook psf/black-pre-commit-mirror to v24 ([#356](https://github.com/hetznercloud/hcloud-python/issues/356)) ([b46397d](https://github.com/hetznercloud/hcloud-python/commit/b46397d761caa60014bd32f7142b79bef9a92e18)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.1.1 ([#358](https://github.com/hetznercloud/hcloud-python/issues/358)) ([7e4645e](https://github.com/hetznercloud/hcloud-python/commit/7e4645e3e38a106f38a7f63810d71a628fead939)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.2.0 ([#361](https://github.com/hetznercloud/hcloud-python/issues/361)) ([5b56ace](https://github.com/hetznercloud/hcloud-python/commit/5b56ace93b8b4fddddbf5610c11fd20bf6f9a561)) +- update pre-commit hook psf/black-pre-commit-mirror to v24.3.0 ([3bbac5d](https://github.com/hetznercloud/hcloud-python/commit/3bbac5dc41ca509d6679fd6b06ae99ca33fd62ee)) +- update pre-commit hook pycqa/flake8 to v7 ([#354](https://github.com/hetznercloud/hcloud-python/issues/354)) ([66a582f](https://github.com/hetznercloud/hcloud-python/commit/66a582f3ce728d92045625885d0634fc96fbc6a0)) +- update pypa/gh-action-pypi-publish action to v1.8.12 ([#365](https://github.com/hetznercloud/hcloud-python/issues/365)) ([55db255](https://github.com/hetznercloud/hcloud-python/commit/55db2551dd0f0ea6a29da4e7a6dce2af8de86eaf)) +- update pypa/gh-action-pypi-publish action to v1.8.14 ([#367](https://github.com/hetznercloud/hcloud-python/issues/367)) ([0cb615f](https://github.com/hetznercloud/hcloud-python/commit/0cb615fe0d852cddbf636c1fdb8538ad60f5a3d9)) + +## [1.33.2](https://github.com/hetznercloud/hcloud-python/compare/v1.33.1...v1.33.2) (2024-01-02) + +### Bug Fixes + +- publish package to PyPI using OIDC auth ([1a0e93b](https://github.com/hetznercloud/hcloud-python/commit/1a0e93bbf1ae6cc747e6c4d8305dafd3e49dbbdc)) + +## [1.33.1](https://github.com/hetznercloud/hcloud-python/compare/v1.33.0...v1.33.1) (2024-01-02) + +### Bug Fixes + +- private object not exported in top level module ([#346](https://github.com/hetznercloud/hcloud-python/issues/346)) ([5281b05](https://github.com/hetznercloud/hcloud-python/commit/5281b0583541b6e0e9b8c7ad75faa42c5d379735)) + +### Dependencies + +- update dependency coverage to >=7.4,<7.5 ([#348](https://github.com/hetznercloud/hcloud-python/issues/348)) ([3ac5711](https://github.com/hetznercloud/hcloud-python/commit/3ac57117e8a68a02cba19c56f850f037c4aca462)) +- update dependency mypy to >=1.8,<1.9 ([#343](https://github.com/hetznercloud/hcloud-python/issues/343)) ([984022f](https://github.com/hetznercloud/hcloud-python/commit/984022fd3888ef856be83de82554d55a8af18dba)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.1 ([#347](https://github.com/hetznercloud/hcloud-python/issues/347)) ([2c24efe](https://github.com/hetznercloud/hcloud-python/commit/2c24efe93bc221846f8dcc91abcf1aad61547875)) + +## [1.33.0](https://github.com/hetznercloud/hcloud-python/compare/v1.32.0...v1.33.0) (2023-12-19) + +### Features + +- add metrics endpoint for load balancers and servers ([#331](https://github.com/hetznercloud/hcloud-python/issues/331)) ([ee3c54f](https://github.com/hetznercloud/hcloud-python/commit/ee3c54fd1b6963533bc9d1e1f9ff57f6c5872cd5)) + +### 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 actions/setup-python action to v5 ([#335](https://github.com/hetznercloud/hcloud-python/issues/335)) ([2ac252d](https://github.com/hetznercloud/hcloud-python/commit/2ac252d18ba6079d5372c6ab9e3f67b4740db465)) +- update dependency sphinx-rtd-theme to v2 ([#330](https://github.com/hetznercloud/hcloud-python/issues/330)) ([7cc4335](https://github.com/hetznercloud/hcloud-python/commit/7cc4335cacab6073cf39a0ecbecf8890903d5bca)) +- update pre-commit hook psf/black-pre-commit-mirror to v23.12.0 ([#338](https://github.com/hetznercloud/hcloud-python/issues/338)) ([38e4748](https://github.com/hetznercloud/hcloud-python/commit/38e4748d3d194d37ea3d0c63683609f5db432e0d)) +- update pre-commit hook pycqa/isort to v5.13.0 ([#336](https://github.com/hetznercloud/hcloud-python/issues/336)) ([3244cfe](https://github.com/hetznercloud/hcloud-python/commit/3244cfef2f90ef52d0fb791d514d6afe481aa4d7)) +- update pre-commit hook pycqa/isort to v5.13.1 ([#337](https://github.com/hetznercloud/hcloud-python/issues/337)) ([020a0ef](https://github.com/hetznercloud/hcloud-python/commit/020a0eff6bc2b63d16b339fd5d4c3ea3610c0509)) +- update pre-commit hook pycqa/isort to v5.13.2 ([#339](https://github.com/hetznercloud/hcloud-python/issues/339)) ([b46df8c](https://github.com/hetznercloud/hcloud-python/commit/b46df8cbb263945c59ce4408e0a7189d19d9c597)) + +## [1.32.0](https://github.com/hetznercloud/hcloud-python/compare/v1.31.0...v1.32.0) (2023-11-17) + +### Features + +- allow returning root_password in servers rebuild ([#276](https://github.com/hetznercloud/hcloud-python/issues/276)) ([38e098a](https://github.com/hetznercloud/hcloud-python/commit/38e098a41154e6561578cd723608fcd7577c3d01)) + +### 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 + +- 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 dependency mypy to >=1.6,<1.7 ([#317](https://github.com/hetznercloud/hcloud-python/issues/317)) ([d248bbd](https://github.com/hetznercloud/hcloud-python/commit/d248bbd4e55f3bcf6a107cfa4f38768df0bf3de5)) +- update dependency pylint to v3 ([#307](https://github.com/hetznercloud/hcloud-python/issues/307)) ([277841d](https://github.com/hetznercloud/hcloud-python/commit/277841dd84ba3b2bbc99a06a3f97e114d1c83dcb)) +- update pre-commit hook asottile/pyupgrade to v3.14.0 ([#308](https://github.com/hetznercloud/hcloud-python/issues/308)) ([07a4513](https://github.com/hetznercloud/hcloud-python/commit/07a4513e284b9ee964bca003d0a9dfd948d39b02)) +- update pre-commit hook asottile/pyupgrade to v3.15.0 ([#312](https://github.com/hetznercloud/hcloud-python/issues/312)) ([c544639](https://github.com/hetznercloud/hcloud-python/commit/c5446394acfa25d23761da4c6b5b75fb6d376b23)) +- update pre-commit hook pre-commit/pre-commit-hooks to v4.5.0 ([#313](https://github.com/hetznercloud/hcloud-python/issues/313)) ([e51eaa9](https://github.com/hetznercloud/hcloud-python/commit/e51eaa990336251c2afc8c83d4c5e6f5e5bb857b)) +- update python docker tag to v3.12 ([#309](https://github.com/hetznercloud/hcloud-python/issues/309)) ([3a1ee67](https://github.com/hetznercloud/hcloud-python/commit/3a1ee675f2c980a4d9e63188e8ffceb64f4797fc)) + +## [1.29.1](https://github.com/hetznercloud/hcloud-python/compare/v1.29.0...v1.29.1) (2023-09-26) + +### Bug Fixes + +- prevent api calls when printing bound models ([#305](https://github.com/hetznercloud/hcloud-python/issues/305)) ([c1de7ef](https://github.com/hetznercloud/hcloud-python/commit/c1de7efc851b3b10e2a50e66268fc8fb0ff648a8)) + +## [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 + +- missing BaseDomain base class inheritance ([#303](https://github.com/hetznercloud/hcloud-python/issues/303)) ([0ee7598](https://github.com/hetznercloud/hcloud-python/commit/0ee759856cb1352f6cc538b7ef86a91cd20380f2)) + +### Dependencies + +- update actions/checkout action to v4 ([#295](https://github.com/hetznercloud/hcloud-python/issues/295)) ([c02b446](https://github.com/hetznercloud/hcloud-python/commit/c02b4468f0e499791bbee8fe48fe7a737985df1f)) +- update dependency sphinx to >=7.2.2,<7.3 ([#291](https://github.com/hetznercloud/hcloud-python/issues/291)) ([10234ea](https://github.com/hetznercloud/hcloud-python/commit/10234ea7bf51a427b18f2b5605d9ffa7ac5f5ee8)) +- update dependency sphinx to v7 ([#211](https://github.com/hetznercloud/hcloud-python/issues/211)) ([f635c94](https://github.com/hetznercloud/hcloud-python/commit/f635c94c23b8ae49283b9b7fcb4fe7b948b203b9)) +- update pre-commit hook asottile/pyupgrade to v3.11.0 ([#298](https://github.com/hetznercloud/hcloud-python/issues/298)) ([4bbd0cc](https://github.com/hetznercloud/hcloud-python/commit/4bbd0ccb0f606e2f90f8242951d3f4d9b86d7aea)) +- update pre-commit hook asottile/pyupgrade to v3.11.1 ([#299](https://github.com/hetznercloud/hcloud-python/issues/299)) ([2f9fcd7](https://github.com/hetznercloud/hcloud-python/commit/2f9fcd7bb80efb8da6eafab0ee70a8dda93eb6f1)) +- update pre-commit hook asottile/pyupgrade to v3.13.0 ([#301](https://github.com/hetznercloud/hcloud-python/issues/301)) ([951dbf3](https://github.com/hetznercloud/hcloud-python/commit/951dbf3e3b3816ffaeb44a583251a5a3a4b90b70)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.3 ([#294](https://github.com/hetznercloud/hcloud-python/issues/294)) ([381e336](https://github.com/hetznercloud/hcloud-python/commit/381e336ff1259fa26cb6abae3b7341cb16229a4b)) +- update pre-commit hook psf/black to v23.9.1 ([#296](https://github.com/hetznercloud/hcloud-python/issues/296)) ([4374a7b](https://github.com/hetznercloud/hcloud-python/commit/4374a7be9f244a72f1fc0c2dd76357cf63f19bfd)) + +### 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 + +- 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 + +- 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 pre-commit hook asottile/pyupgrade to v3.10.1 ([#261](https://github.com/hetznercloud/hcloud-python/issues/261)) ([efa5780](https://github.com/hetznercloud/hcloud-python/commit/efa5780d0de3080bffe43994c064a0f1bcf6da43)) +- update pre-commit hook pre-commit/mirrors-prettier to v3.0.1 ([#269](https://github.com/hetznercloud/hcloud-python/issues/269)) ([2239b0b](https://github.com/hetznercloud/hcloud-python/commit/2239b0bc9beae457215c6514b0b823cc84a4a463)) +- update pre-commit hook pycqa/flake8 to v6.1.0 ([#260](https://github.com/hetznercloud/hcloud-python/issues/260)) ([fd01384](https://github.com/hetznercloud/hcloud-python/commit/fd013842f7f94e98520ed403a8cd91b68a4c4e5c)) + +### Documentation + +- update documentation ([#247](https://github.com/hetznercloud/hcloud-python/issues/247)) ([e63741f](https://github.com/hetznercloud/hcloud-python/commit/e63741fab50524f4e4098af5c77f806915ae93c8)) +- update hetzner logo ([#264](https://github.com/hetznercloud/hcloud-python/issues/264)) ([ee79851](https://github.com/hetznercloud/hcloud-python/commit/ee79851dbf00e50d7f6b398fd4323f3e14831831)) + +## [1.26.0](https://github.com/hetznercloud/hcloud-python/compare/v1.25.0...v1.26.0) (2023-07-19) + +### Features + +- add **repr** method to domains ([#246](https://github.com/hetznercloud/hcloud-python/issues/246)) ([4c22765](https://github.com/hetznercloud/hcloud-python/commit/4c227659bfb61551e44c41315b135039576960d3)) +- drop support for python 3.7 ([#242](https://github.com/hetznercloud/hcloud-python/issues/242)) ([2ce71e9](https://github.com/hetznercloud/hcloud-python/commit/2ce71e9ded5e9bb87ce96519ce59db942f4f9670)) + +## [1.25.0](https://github.com/hetznercloud/hcloud-python/compare/v1.24.0...v1.25.0) (2023-07-14) + +### Features + +- add details to raise exceptions ([#240](https://github.com/hetznercloud/hcloud-python/issues/240)) ([cf64e54](https://github.com/hetznercloud/hcloud-python/commit/cf64e549a2b28aea91062dea67db8733b4ecdd6f)) +- move hcloud.hcloud module to hcloud.\_client ([#243](https://github.com/hetznercloud/hcloud-python/issues/243)) ([413472d](https://github.com/hetznercloud/hcloud-python/commit/413472d7af1602b872a9b56324b9bffd0067eee6)) + +### 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 + +- 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 + +- handle Z timezone in ISO8601 datetime format ([#228](https://github.com/hetznercloud/hcloud-python/issues/228)) ([6a5c3f4](https://github.com/hetznercloud/hcloud-python/commit/6a5c3f42c092610c4a82cb79c0052499563549dc)), closes [#226](https://github.com/hetznercloud/hcloud-python/issues/226) + +## [1.23.0](https://github.com/hetznercloud/hcloud-python/compare/v1.22.0...v1.23.0) (2023-06-26) + +### Features + +- remove python-dateutil dependency ([#221](https://github.com/hetznercloud/hcloud-python/issues/221)) ([8ea4aa0](https://github.com/hetznercloud/hcloud-python/commit/8ea4aa0ad12e85eeb14c81dfa2195e1a6ee79a76)) + +### 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 + +- 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) + +- docs: link to PrivateNet broken by @apricote in [#177](https://github.com/hetznercloud/hcloud-python/issues/177) +- feat: add support for ARM APIs by @apricote in [#182](https://github.com/hetznercloud/hcloud-python/issues/182) + +## v1.18.2 (2022-12-27) + +- fix: remove unused future dependency by @apricote in [#173](https://github.com/hetznercloud/hcloud-python/issues/173) +- 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) + +- 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) + +## v1.18.0 (2022-08-17) + +- Remove use of external mock module by @s-t-e-v-e-n-k in [#162](https://github.com/hetznercloud/hcloud-python/issues/162) +- document installation path via conda-forge by @s-m-e in [#149](https://github.com/hetznercloud/hcloud-python/issues/149) +- Drop # -- coding: utf-8 -- from files by @jonasdlindner in [#154](https://github.com/hetznercloud/hcloud-python/issues/154) +- Simplify Requirement Constraints by @LKaemmerling in [#163](https://github.com/hetznercloud/hcloud-python/issues/163) +- Add validation helper for Label Values/Keys by @LKaemmerling in [#164](https://github.com/hetznercloud/hcloud-python/issues/164) + +## v1.17.0 (2022-06-29) + +- Add primary IP support by @LKaemmerling in [#160](https://github.com/hetznercloud/hcloud-python/issues/160) + +## v1.16.0 (2021-08-17) + +- Feature: Add support for Load Balancer DNS PTRs + +## v1.15.0 (2021-08-16) + +- Feature: Add support for Placement Groups + +## v1.14.1 (2021-08-10) + +- Bugfix: Fix crash on extra fields in public_net response +- Improvement: Format code with black + +## v1.14.0 (2021-08-03) + +- Feature: Add support for Firewall rule descriptions + +## v1.13.0 (2021-07-16) + +- Feature: Add support for Firewall Protocols ESP and GRE +- Feature: Add support for Image Type APP +- Feature: Add support for creating Firewalls with Firewalls +- Feature: Add support for Label Selectors in Firewalls +- Improvement: Improve handling of underlying TCP connections. Now for every client instance a single TCP connection is used instead of one per call. + +- Note: Support for Python 2.7 and Python 3.5 was removed + +## v1.12.0 (2021-04-06) + +- Feature: Add support for managed Certificates + +## v1.11.0 (2021-03-11) + +- Feature: Add support for Firewalls +- Feature: Add `primary_disk_size` to `Server` Domain + +## v1.10.0 (2020-11-03) + +- Feature: Add `include_deprecated` filter to `get_list` and `get_all` on `ImagesClient` +- Feature: Add vSwitch support to `add_subnet` on `NetworksClient` +- Feature: Add subnet type constants to `NetworkSubnet` domain (`NetworkSubnet.TYPE_CLOUD`, `NetworkSubnet.TYPE_VSWITCH`) + +## v1.9.1 (2020-08-11) + +- Bugfix: BoundLoadBalancer serialization failed when using IP targets + +## v1.9.0 (2020-08-10) + +- Feature: Add `included_traffic`, `outgoing_traffic` and `ingoing_traffic` properties to Load Balancer domain +- Feature: Add `change_type`-method to `LoadBalancersClient` +- Feature: Add support for `LoadBalancerTargetLabelSelector` +- Feature: Add support for `LoadBalancerTargetLabelSelector` + +## v1.8.2 (2020-07-20) + +- Fix: Loosen up the requirements. + +## v1.8.1 (2020-06-29) + +- Fix Load Balancer Client. +- Fix: Unify setting of request parameters within `get_list` methods. + +## 1.8.0 (2020-06-22) + +- Feature: Add Load Balancers **Attention: The Load Balancer support in v1.8.0 is kind of broken. Please use v1.8.1** +- Feature: Add Certificates + +## 1.7.1 (2020-06-15) + +- Feature: Add requests 2.23 support + +## 1.7.0 (2020-06-05) + +- Feature: Add support for the optional 'networks' parameter on server creation. +- Feature: Add python 3.9 support +- Feature: Add subnet type `cloud` + +## 1.6.3 (2020-01-09) + +- Feature: Add 'created' property to SSH Key domain +- Fix: Remove ISODatetime Descriptor because it leads to wrong dates + +## 1.6.2 (2019-10-15) + +- Fix: future dependency requirement was too strict + +## 1.6.1 (2019-10-01) + +- Fix: python-dateutil dependency requirement was too strict + +## 1.6.0 (2019-09-17) + +- Feature: Add missing `get_by_name` on `FloatingIPsClient` + +## 1.5.0 (2019-09-16) + +- Fix: ServersClient.create_image fails when specifying the `labels` +- Feature: Add support for `name` on Floating IPs + +## 1.4.1 (2019-08-19) + +- Fix: Documentation for `NetworkRoute` domain was missing + +- Fix: `requests` dependency requirement was to strict + +## 1.4.0 (2019-07-29) + +- Feature: Add `mac_address` to Server PrivateNet domain + +- Feature: Add python 3.8 support + +## 1.3.0 (2019-07-10) + +- Feature: Add status filter for servers, images and volumes +- Feature: Add 'created' property to Floating IP domain +- Feature: Add 'Networks' support + +## 1.2.1 (2019-03-13) + +- Fix: BoundVolume.server server property now casted to the 'BoundServer'. + +## 1.2.0 (2019-03-06) + +- Feature: Add `get_by_fingerprint`-method for ssh keys +- Fix: Create Floating IP with location raises an error because no action was given. + +## 1.1.0 (2019-02-27) + +- Feature: Add `STATUS`-constants for server and volume status + +## 1.0.1 (2019-02-22) + +Fix: Ignore unknown fields in API response instead of raising an error + +## 1.0.0 (2019-02-21) + +- First stable release. + + You can find the documentation under https://hcloud-python.readthedocs.io/en/stable/ + +## 0.1.0 (2018-12-20) + +- First release on GitHub. diff --git a/CHANGELOG.rst b/CHANGELOG.rst deleted file mode 100644 index 52aecdb9..00000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,169 +0,0 @@ -======= -History -======= - -v1.16.0 (2021-08-17) ---------------------- -* Feature: Add support for Load Balancer DNS PTRs - -v1.15.0 (2021-08-16) ---------------------- -* Feature: Add support for Placement Groups - -v1.14.1 (2021-08-10) ---------------------- -* Bugfix: Fix crash on extra fields in public_net response -* Improvement: Format code with black - -v1.14.0 (2021-08-03) ---------------------- -* Feature: Add support for Firewall rule descriptions - -v1.13.0 (2021-07-16) ---------------------- -* Feature: Add support for Firewall Protocols ESP and GRE -* Feature: Add support for Image Type APP -* Feature: Add support for creating Firewalls with Firewalls -* Feature: Add support for Label Selectors in Firewalls -* Improvement: Improve handling of underlying TCP connections. Now for every client instance a single TCP connection is used instead of one per call. - -* Note: Support for Python 2.7 and Python 3.5 was removed - -v1.12.0 (2021-04-06) ---------------------- -* Feature: Add support for managed Certificates - -v1.11.0 (2021-03-11) ---------------------- -* Feature: Add support for Firewalls -* Feature: Add `primary_disk_size` to `Server` Domain - -v1.10.0 (2020-11-03) ---------------------- - -* Feature: Add `include_deprecated` filter to `get_list` and `get_all` on `ImagesClient` -* Feature: Add vSwitch support to `add_subnet` on `NetworksClient` -* Feature: Add subnet type constants to `NetworkSubnet` domain (`NetworkSubnet.TYPE_CLOUD`, `NetworkSubnet.TYPE_VSWITCH`) - -v1.9.1 (2020-08-11) --------------------- - -* Bugfix: BoundLoadBalancer serialization failed when using IP targets - -v1.9.0 (2020-08-10) --------------------- - -* Feature: Add `included_traffic`, `outgoing_traffic` and `ingoing_traffic` properties to Load Balancer domain -* Feature: Add `change_type`-method to `LoadBalancersClient` -* Feature: Add support for `LoadBalancerTargetLabelSelector` -* Feature: Add support for `LoadBalancerTargetLabelSelector` - -v1.8.2 (2020-07-20) --------------------- - -* Fix: Loosen up the requirements. - - -v1.8.1 (2020-06-29) --------------------- - -* Fix Load Balancer Client. -* Fix: Unify setting of request parameters within `get_list` methods. - -1.8.0 (2020-06-22) --------------------- - -* Feature: Add Load Balancers **Attention: The Load Balancer support in v1.8.0 is kind of broken. Please use v1.8.1** -* Feature: Add Certificates - - -1.7.1 (2020-06-15) --------------------- - -* Feature: Add requests 2.23 support - -1.7.0 (2020-06-05) --------------------- - -* Feature: Add support for the optional 'networks' parameter on server creation. -* Feature: Add python 3.9 support -* Feature: Add subnet type `cloud` - -1.6.3 (2020-01-09) --------------------- - -* Feature: Add 'created' property to SSH Key domain -* Fix: Remove ISODatetime Descriptor because it leads to wrong dates - -1.6.2 (2019-10-15) -------------------- -* Fix: future dependency requirement was too strict - -1.6.1 (2019-10-01) -------------------- -* Fix: python-dateutil dependency requirement was too strict - -1.6.0 (2019-09-17) -------------------- - -* Feature: Add missing `get_by_name` on `FloatingIPsClient` - -1.5.0 (2019-09-16) -------------------- - -* Fix: ServersClient.create_image fails when specifying the `labels` -* Feature: Add support for `name` on Floating IPs - -1.4.1 (2019-08-19) ------------------- - -* Fix: Documentation for `NetworkRoute` domain was missing - -* Fix: `requests` dependency requirement was to strict - -1.4.0 (2019-07-29) ------------------- - -* Feature: Add `mac_address` to Server PrivateNet domain - -* Feature: Add python 3.8 support - -1.3.0 (2019-07-10) ------------------- - -* Feature: Add status filter for servers, images and volumes -* Feature: Add 'created' property to Floating IP domain -* Feature: Add 'Networks' support - -1.2.1 (2019-03-13) ------------------- - -* Fix: BoundVolume.server server property now casted to the 'BoundServer'. - -1.2.0 (2019-03-06) ------------------- - -* Feature: Add `get_by_fingerprint`-method for ssh keys -* Fix: Create Floating IP with location raises an error because no action was given. - -1.1.0 (2019-02-27) ------------------- - -* Feature: Add `STATUS`-constants for server and volume status - -1.0.1 (2019-02-22) ------------------- - - Fix: Ignore unknown fields in API response instead of raising an error - -1.0.0 (2019-02-21) ------------------- - -* First stable release. - - You can find the documentation under https://hcloud-python.readthedocs.io/en/latest/ - -0.1.0 (2018-12-20) ------------------- - -* First release on GitHub. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index a485ed14..ba0c50d8 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -55,41 +55,27 @@ If you are proposing a feature: Get Started! ------------- -Ready to contribute? Here's how to set up `hcloud-python` for local development. +Ready to contribute? Here's how to set up ``hcloud-python`` for local development. -1. Fork the `hcloud-python` repo on GitHub. +1. Fork the ``hcloud-python`` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/hcloud-python.git -3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: - - $ mkvirtualenv hcloud-python - $ cd hcloud-python/ - $ python setup.py develop - +3. Read the ``Development`` section in the ``README.md``, to setup your development environment. 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. -5. When you're done making changes, check that your changes pass flake8 and the - tests, including testing other Python versions with tox:: - - $ flake8 hetznercloud tests - $ python setup.py test or py.test - $ tox - - To get flake8 and tox, just pip install them into your virtualenv. - -6. Commit your changes and push your branch to GitHub:: +5. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature -7. Submit a pull request through the GitHub website. +6. Submit a pull request through the GitHub website. Pull Request Guidelines ------------------------ @@ -99,27 +85,6 @@ Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the - feature to the list in README.rst. -3. The pull request should work for Python 2.7, 3.5 and 3.6, and for PyPy. Check - https://travis-ci.org/hetznercloud/hcloud-python/pull_requests - and make sure that the tests pass for all supported Python versions. - -Tips ------ - -To run a subset of tests:: - -$ py.test tests.test_hetznercloud - - -How to release ---------------- - -A reminder for the maintainers on how to release a new version. -Make sure all your changes are committed (including an entry in CHANGELOG.rst). -Then run:: - -1. Change the version under /hcloud/version.py -2. Push the change to the `master` branch and tag an new release through the `Github UI `_. - -Travis will then deploy to PyPI if tests pass. + feature to the list in README.md. +3. The pull request should work for all the versions of Python the library supports, and + for PyPy. diff --git a/LICENSE b/LICENSE index a3f4553e..5eadcc40 100644 --- a/LICENSE +++ b/LICENSE @@ -19,4 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - diff --git a/MANIFEST.in b/MANIFEST.in index 2d23d6cc..f3658c7c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,10 +1,12 @@ +include CHANGELOG.md include CONTRIBUTING.rst -include CHANGELOG.rst include LICENSE -include README.rst +include README.md + +include hcloud/py.typed recursive-include tests * recursive-exclude * __pycache__ recursive-exclude * *.py[co] -recursive-include docs *.rst conf.py Makefile make.bat *.jpg *.png *.gif +recursive-include docs conf.py Makefile make.bat *.rst *.md *.jpg *.png *.gif *.js *.svg diff --git a/Makefile b/Makefile index bf621e39..cd3ea5e9 100644 --- a/Makefile +++ b/Makefile @@ -1,85 +1,37 @@ -.PHONY: clean clean-test clean-pyc clean-build docs help -.DEFAULT_GOAL := help +SHELL := bash +.PHONY: test coverage docs clean -define BROWSER_PYSCRIPT -import os, webbrowser, sys +venv: + python3 -m venv venv + venv/bin/pip install -e .[docs,test] -try: - from urllib import pathname2url -except: - from urllib.request import pathname2url +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 -webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1]))) -endef -export BROWSER_PYSCRIPT +test: venv + venv/bin/pytest -v -define PRINT_HELP_PYSCRIPT -import re, sys +coverage: venv + venv/bin/coverage run -m pytest -v + venv/bin/coverage report --show-missing + venv/bin/coverage xml -for line in sys.stdin: - match = re.match(r'^([a-zA-Z_-]+):.*?## (.*)$$', line) - if match: - target, help = match.groups() - print("%-20s %s" % (target, help)) -endef -export PRINT_HELP_PYSCRIPT - -BROWSER := python -c "$$BROWSER_PYSCRIPT" - -help: - @python -c "$$PRINT_HELP_PYSCRIPT" < $(MAKEFILE_LIST) - -clean: clean-build clean-pyc clean-test ## remove all build, test, coverage and Python artifacts - -clean-build: ## remove build artifacts - rm -fr build/ - rm -fr dist/ - rm -fr .eggs/ - find . -name '*.egg-info' -exec rm -fr {} + - find . -name '*.egg' -exec rm -f {} + - -clean-pyc: ## remove Python file artifacts - find . -name '*.pyc' -exec rm -f {} + - find . -name '*.pyo' -exec rm -f {} + - find . -name '*~' -exec rm -f {} + - find . -name '__pycache__' -exec rm -fr {} + - -clean-test: ## remove test and coverage artifacts - rm -fr .tox/ - rm -f .coverage - rm -fr htmlcov/ - rm -fr .pytest_cache - -lint: ## check style with flake8 - flake8 hcloud tests - -test: ## run tests quickly with the default Python - py.test - -test-all: ## run tests on every Python version with tox - tox - -coverage: ## check code coverage quickly with the default Python - coverage run --source hcloud -m pytest - coverage report -m - coverage html - $(BROWSER) htmlcov/index.html - -docs: ## generate Sphinx HTML documentation, including API docs +export SPHINXBUILD=../venv/bin/sphinx-build +docs: venv $(MAKE) -C docs clean $(MAKE) -C docs html - $(BROWSER) docs/_build/html/index.html - -servedocs: docs ## compile the docs watching for changes - watchmedo shell-command -p '*.rst' -c '$(MAKE) -C docs html' -R -D . - -release: dist ## package and upload a release - twine upload dist/* - -dist: clean ## builds source and wheel package - python setup.py sdist - python setup.py bdist_wheel - ls -l dist - -install: clean ## install the package to the active Python's site-packages - python setup.py install + xdg-open docs/_build/html/index.html + +docs-dev: venv docs + venv/bin/watchmedo shell-command \ + --patterns="*.py;*.rst;*.md;*.css" \ + --ignore-pattern=".git/*" \ + --recursive \ + --drop \ + --command="$(MAKE) -C docs html" \ + . + +clean: + git clean -xdf diff --git a/README.md b/README.md new file mode 100644 index 00000000..50da524b --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# Hetzner Cloud Python + +[![](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://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/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 + +Install the `hcloud` library: + +```sh +pip install hcloud +``` + +For more installation details, please see the [installation docs](https://hcloud-python.readthedocs.io/en/stable/installation.html). + +Here is an example that creates a server and list them: + +```python +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 + 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="cx23"), + image=Image(name="ubuntu-22.04"), +) +server = response.server +print(f"{server.id=} {server.name=} {server.status=}") +print(f"root password: {response.root_password}") + +# List your servers +servers = client.servers.get_all() +for server in servers: + print(f"{server.id=} {server.name=} {server.status=}") +``` + +- 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: + +```sh +make venv +source venv/bin/activate +``` + +You may setup [`pre-commit`](https://pre-commit.com/) to run before you commit changes, this removes the need to run it manually afterwards: + +```sh +pre-commit install +``` + +You can then run different tasks defined in the `Makefile`, below are the most important ones: + +Build the documentation and open it in your browser: + +```sh +make docs +``` + +Lint the code: + +```sh +make lint +``` + +Run tests using the current `python3` version: + +```sh +make test +``` + +You may also run the tests for multiple `python3` versions using `tox`: + +```sh +tox . +``` + +### Deprecations implementation + +When deprecating a module or a function, you must: + +- Update the docstring with a `deprecated` notice: + +```py +"""Get image by name + +.. deprecated:: 1.19 + Use :func:`hcloud.images.client.ImagesClient.get_by_name_and_architecture` instead. +""" +``` + +- Raise a warning when the deprecated module or function is being used: + +```py +warnings.warn( + "The 'hcloud.images.client.ImagesClient.get_by_name' method is deprecated, please use the " + "'hcloud.images.client.ImagesClient.get_by_name_and_architecture' method instead.", + DeprecationWarning, + stacklevel=2, +) +``` + +### 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/README.rst b/README.rst deleted file mode 100644 index fe3a76cb..00000000 --- a/README.rst +++ /dev/null @@ -1,67 +0,0 @@ -Hetzner Cloud Python -==================== - - -.. image:: https://github.com/hetznercloud/hcloud-python/workflows/Unit%20Tests/badge.svg - :target: https://github.com/hetznercloud/hcloud-cloud-controller-manager/actions -.. image:: https://github.com/hetznercloud/hcloud-python/workflows/Code%20Style/badge.svg - :target: https://github.com/hetznercloud/hcloud-cloud-controller-manager/actions -.. image:: https://readthedocs.org/projects/hcloud-python/badge/?version=latest - :target: https://hcloud-python.readthedocs.io -.. image:: https://img.shields.io/pypi/pyversions/hcloud.svg - :target: https://pypi.org/project/hcloud/ - -Official Hetzner Cloud python library - -The library's documentation is available at `ReadTheDocs`_, the public API documentation is available at https://docs.hetzner.cloud. - -.. _ReadTheDocs: https://hcloud-python.readthedocs.io - -Usage example -------------- - -After the documentation has been created, click on `Usage` section - -Or open `docs/usage.rst` - -You can find some more examples under `examples/`. - - -Supported Python versions -------------------------- - -We support python versions until `end-of-life`_. - -.. _end-of-life: https://devguide.python.org/#status-of-python-branches - -Development ------------ - -Setup Dev Environment ---------------------- -1) `mkvirtualenv hcloud-python` - -2) `pip install -e .` or `pip install -e .[docs]` to be able to build docs - - -Run tests ---------- -* `tox .` -* You can specify environment e.g `tox -e py36` -* You can test the code style with `tox -e flake8` - -Create Documentation --------------------- - -Run `make docs`. This will also open a documentation in a tab in your default browser. - - -Style Guide -------------- -* **Type Hints**: If the type hint line is too long use inline hinting. Maximum inline type hint line should be 150 chars. - -License -------------- -The MIT License (MIT). Please see `License File`_ for more information. - -.. _License File: https://github.com/hetznercloud/hcloud-python/blob/master/LICENSE diff --git a/docs/Makefile b/docs/Makefile index 26d1985e..d4bb2cbb 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,10 +1,10 @@ # Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = python -msphinx -SPHINXPROJ = hcloud +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build SOURCEDIR = . BUILDDIR = _build diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..339b9cce --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,12 @@ +.wy-side-nav-search > div.version { + /* Version in Nav is off-white by default, but we restyle the header to have an off-white bg */ + color: #404040; +} + +.wy-side-nav-search input[type="text"] { + border-color: #404040; +} + +.logo { + margin: 1rem !important; +} diff --git a/docs/_static/favicon.png b/docs/_static/favicon.png new file mode 100644 index 00000000..f54ff679 Binary files /dev/null and b/docs/_static/favicon.png differ diff --git a/docs/_static/js/open_links_in_new_tab.js b/docs/_static/js/open_links_in_new_tab.js index 7981bcb1..6c17a212 100644 --- a/docs/_static/js/open_links_in_new_tab.js +++ b/docs/_static/js/open_links_in_new_tab.js @@ -1,3 +1,3 @@ -$(document).ready(function() { - $("a[href^='http']").attr('target','_blank'); -}); \ No newline at end of file +$(document).ready(function () { + $("a[href^='http']").attr("target", "_blank"); +}); diff --git a/docs/_static/logo-hetzner-online.svg b/docs/_static/logo-hetzner-online.svg deleted file mode 100644 index bc070ab8..00000000 --- a/docs/_static/logo-hetzner-online.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/docs/_static/logo-hetzner.svg b/docs/_static/logo-hetzner.svg new file mode 100644 index 00000000..95c2e8cc --- /dev/null +++ b/docs/_static/logo-hetzner.svg @@ -0,0 +1,28 @@ + + + + + Element 1 + + + + + + diff --git a/docs/api.clients.actions.rst b/docs/api.clients.actions.rst index 3c7f47cd..aecd2fe0 100644 --- a/docs/api.clients.actions.rst +++ b/docs/api.clients.actions.rst @@ -1,13 +1,15 @@ ActionsClient ================== +.. autoclass:: hcloud.actions.client.ResourceActionsClient + :members: .. autoclass:: hcloud.actions.client.ActionsClient :members: + :inherited-members: .. autoclass:: hcloud.actions.client.BoundAction :members: .. autoclass:: hcloud.actions.domain.Action :members: - diff --git a/docs/api.clients.certificates.rst b/docs/api.clients.certificates.rst index 18c5e929..b5ec8500 100644 --- a/docs/api.clients.certificates.rst +++ b/docs/api.clients.certificates.rst @@ -10,4 +10,3 @@ CertificateClient .. autoclass:: hcloud.certificates.domain.Certificate :members: - diff --git a/docs/api.clients.datacenters.rst b/docs/api.clients.datacenters.rst index fc7f9c9f..9354313e 100644 --- a/docs/api.clients.datacenters.rst +++ b/docs/api.clients.datacenters.rst @@ -13,4 +13,3 @@ DatacentersClient .. autoclass:: hcloud.datacenters.domain.DatacenterServerTypes :members: - diff --git a/docs/api.clients.firewalls.rst b/docs/api.clients.firewalls.rst index 81ee6658..ad7358a6 100644 --- a/docs/api.clients.firewalls.rst +++ b/docs/api.clients.firewalls.rst @@ -19,4 +19,3 @@ FirewallsClient .. autoclass:: hcloud.firewalls.domain.CreateFirewallResponse :members: - diff --git a/docs/api.clients.images.rst b/docs/api.clients.images.rst index 78142df1..48833aba 100644 --- a/docs/api.clients.images.rst +++ b/docs/api.clients.images.rst @@ -13,4 +13,3 @@ ImagesClient .. autoclass:: hcloud.images.domain.CreateImageResponse :members: - diff --git a/docs/api.clients.isos.rst b/docs/api.clients.isos.rst index 0196fade..b3a71338 100644 --- a/docs/api.clients.isos.rst +++ b/docs/api.clients.isos.rst @@ -10,4 +10,3 @@ ISOsClient .. autoclass:: hcloud.isos.domain.Iso :members: - diff --git a/docs/api.clients.load_balancers.rst b/docs/api.clients.load_balancers.rst index f407f20f..dfb2780a 100644 --- a/docs/api.clients.load_balancers.rst +++ b/docs/api.clients.load_balancers.rst @@ -20,12 +20,15 @@ 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 :members: +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerTargetHealthStatus + :members: + .. autoclass:: hcloud.load_balancers.domain.LoadBalancerTargetLabelSelector :members: diff --git a/docs/api.clients.locations.rst b/docs/api.clients.locations.rst index 3273b0ef..90012f6b 100644 --- a/docs/api.clients.locations.rst +++ b/docs/api.clients.locations.rst @@ -10,4 +10,3 @@ LocationsClient .. autoclass:: hcloud.locations.domain.Location :members: - diff --git a/docs/api.clients.placement_groups.rst b/docs/api.clients.placement_groups.rst index fe3638ac..83457dfb 100644 --- a/docs/api.clients.placement_groups.rst +++ b/docs/api.clients.placement_groups.rst @@ -1,5 +1,5 @@ PlacementGroupsClient -================== +===================== .. autoclass:: hcloud.placement_groups.client.PlacementGroupsClient diff --git a/docs/api.clients.primary_ips.rst b/docs/api.clients.primary_ips.rst new file mode 100644 index 00000000..3d326660 --- /dev/null +++ b/docs/api.clients.primary_ips.rst @@ -0,0 +1,12 @@ +PrimaryIPsClient +================== + + +.. autoclass:: hcloud.primary_ips.client.PrimaryIPsClient + :members: + +.. autoclass:: hcloud.primary_ips.client.BoundPrimaryIP + :members: + +.. autoclass:: hcloud.primary_ips.domain.PrimaryIP + :members: diff --git a/docs/api.clients.server_types.rst b/docs/api.clients.server_types.rst index 503bc888..90cee18c 100644 --- a/docs/api.clients.server_types.rst +++ b/docs/api.clients.server_types.rst @@ -9,4 +9,3 @@ ServerTypesClient .. autoclass:: hcloud.server_types.domain.ServerType :members: - diff --git a/docs/api.clients.servers.rst b/docs/api.clients.servers.rst index 6f6c2869..58362561 100644 --- a/docs/api.clients.servers.rst +++ b/docs/api.clients.servers.rst @@ -23,6 +23,9 @@ ServersClient .. autoclass:: hcloud.servers.domain.CreateServerResponse :members: +.. autoclass:: hcloud.servers.domain.ServerCreatePublicNetwork + :members: + .. autoclass:: hcloud.servers.domain.ResetPasswordResponse :members: diff --git a/docs/api.clients.ssh_keys.rst b/docs/api.clients.ssh_keys.rst index fbbe3c5b..97d6e73a 100644 --- a/docs/api.clients.ssh_keys.rst +++ b/docs/api.clients.ssh_keys.rst @@ -10,4 +10,3 @@ SSHKeysClient .. autoclass:: hcloud.ssh_keys.domain.SSHKey :members: - 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/api.deprecation.rst b/docs/api.deprecation.rst new file mode 100644 index 00000000..844a60c7 --- /dev/null +++ b/docs/api.deprecation.rst @@ -0,0 +1,5 @@ +Deprecation Info +================== + +.. autoclass:: hcloud.deprecation.domain.DeprecationInfo + :members: diff --git a/docs/api.helpers.rst b/docs/api.helpers.rst new file mode 100644 index 00000000..6121a11a --- /dev/null +++ b/docs/api.helpers.rst @@ -0,0 +1,6 @@ +Helpers +================== + + +.. autoclass:: hcloud.helpers.labels.LabelValidator + :members: diff --git a/docs/api.rst b/docs/api.rst index e999a32f..1bb9b638 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,4 +1,4 @@ -hcloud-python API +API References ================== Main Interface @@ -19,11 +19,26 @@ API Clients Exceptions --------------- +.. autoclass:: hcloud.HCloudException + :members: + .. autoclass:: hcloud.APIException :members: +.. autoclass:: hcloud.actions.domain.ActionException + :members: + .. autoclass:: hcloud.actions.domain.ActionFailedException :members: .. autoclass:: hcloud.actions.domain.ActionTimeoutException :members: + +Other +------------- + +.. toctree:: + :maxdepth: 3 + + api.helpers + api.deprecation diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..67c32d35 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,2 @@ +:::{include} ../CHANGELOG.md +::: diff --git a/docs/changelog.rst b/docs/changelog.rst deleted file mode 100644 index 565b0521..00000000 --- a/docs/changelog.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CHANGELOG.rst diff --git a/docs/conf.py b/docs/conf.py old mode 100755 new mode 100644 index 6ec4f486..df08f92c --- a/docs/conf.py +++ b/docs/conf.py @@ -1,161 +1,61 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# -# hcloud documentation build configuration file, created by -# sphinx-quickstart on Fri Jun 9 13:47:02 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. +from __future__ import annotations -# If extensions (or modules to document with autodoc) are in another -# directory, add these directories to sys.path here. If the directory is -# relative to the documentation root, use os.path.abspath to make it -# absolute, like shown here. -# import os import sys +from datetime import datetime sys.path.insert(0, os.path.abspath("..")) -from hcloud.__version__ import VERSION # noqa - -# -- General configuration --------------------------------------------- - -# If your documentation needs a minimal Sphinx _version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] +import hcloud # noqa -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: +# Configuration file for the Sphinx documentation builder. # -# source_suffix = ['.rst', '.md'] -# source_suffix = '.rst' +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -# The master toctree document. -master_doc = "index" +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# General information about the project. -project = u"Hetzner Cloud Python" -copyright = u"2019, Hetzner Cloud GmbH" -author = u"Hetzner Cloud GmbH" +project = "Hetzner Cloud Python" +author = "Hetzner Cloud GmbH" +copyright = f"{datetime.now().year}, {author}" -# The version info for the project you're documenting, acts as replacement -# for |version| and |release|, also used in various other places throughout -# the built documents. -# -# The short X.Y _version. -version = VERSION -# The full _version, including alpha/beta/rc tags. -release = VERSION +version = hcloud.__version__ +release = hcloud.__version__ -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path +extensions = ["myst_parser", "sphinx.ext.autodoc", "sphinx.ext.viewcode"] +templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", +} -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). +# A boolean that decides whether module names are prepended to all object names (for +# object types where a “module” of some kind is defined), e.g. for py:function +# directives. Default is True. add_module_names = False -# -- Options for HTML output ------------------------------------------- +# Myst Parser +myst_enable_extensions = ["colon_fence"] -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" -html_logo = "_static/logo-hetzner-online.svg" -# Theme options are theme-specific and customize the look and feel of a -# theme further. For a list of options available for each theme, see the -# documentation. -# -html_theme_options = { - "logo_only": True, -} +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". +html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] -# -- Options for HTMLHelp output --------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = "hclouddoc" - -# -- Options for LaTeX output ------------------------------------------ - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', +html_logo = "_static/logo-hetzner.svg" +html_favicon = "_static/favicon.png" +# Theme options are theme-specific and customize the look and feel of a theme further. +# For a list of options available for each theme, see the documentation. +html_theme_options = { + "logo_only": True, + "style_nav_header_background": "#fff", } - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass -# [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "hcloud.tex", - u"Hetzner Cloud Python Documentation", - u"Hetzner Cloud GmbH", - "manual", - ), -] - -# -- Options for manual page output ------------------------------------ - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, u"Hetzner Cloud Python Documentation", [author], 1)] - -# -- Options for Texinfo output ---------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - u"Hetzner Cloud Python Documentation", - author, - "HCloud-python is a library for the Hetzner Cloud API.", - "Miscellaneous", - ), +html_css_files = [ + "custom.css", ] - -source_suffix = [".rst"] diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..32c1f094 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,16 @@ +:::{toctree} +:maxdepth: 4 +:hidden: + +self +installation.rst +api.rst +Hetzner Cloud API Documentation +contributing.rst +upgrading.md +changelog.md + +::: + +:::{include} ../README.md +::: diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index b5605a32..00000000 --- a/docs/index.rst +++ /dev/null @@ -1,63 +0,0 @@ -.. toctree:: - :maxdepth: 4 - :hidden: - - self - installation - samples - api - Hetzner Cloud API Documentation - contributing - changelog - -Hetzner Cloud Python -==================== - - -.. image:: https://travis-ci.com/hetznercloud/hcloud-python.svg?branch=master - :target: https://travis-ci.com/hetznercloud/hcloud-python -.. image:: https://readthedocs.org/projects/hcloud-python/badge/?version=latest - :target: https://hcloud-python.readthedocs.io - -This is the official `Hetzner Cloud`_ python library. - -.. _Hetzner Cloud: https://www.hetzner.com/cloud - -Examples -------------- - -Create Server -------------- -.. code-block:: python - :linenos: - - from hcloud import Client - from hcloud.server_types.domain import ServerType - from hcloud.images.domain import Image - - client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here between the quotes - response = client.servers.create(name="my-server", server_type=ServerType(name="cx11"), image=Image(name="ubuntu-20.04")) - server = response.server - print(server) - print("Root Password: " + response.root_password) - -List Servers ------------- -.. code-block:: python - :linenos: - - from hcloud import Client - - client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here between the quotes - servers = client.servers.get_all() - print(servers) - -You can find more examples in the `Example Folder`_ in the Github Repository. - -.. _Example Folder: https://github.com/hetznercloud/hcloud-python/tree/master/examples - -License -------- -The MIT License (MIT). Please see `License File`_ for more information. - -.. _License File: https://github.com/hetznercloud/hcloud-python/blob/master/LICENSE diff --git a/docs/installation.rst b/docs/installation.rst index 50448b81..0b6f0dba 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -23,10 +23,22 @@ you through the process. .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ +Via conda (Third-Party) +----------------------- + +Hetzner Cloud Python is also available as a ``conda``-package via `conda-forge`. This package is not maintained by Hetzner Cloud and might be outdated._: + +.. code-block:: console + + $ conda install -c conda-forge hcloud + +.. _conda-forge: https://conda-forge.org/ + + From sources ------------ -The sources for Hetzner Cloud Python can be downloaded from the `Github repo`_. +The sources for Hetzner Cloud Python can be downloaded from the Github repo. You can either clone the public repository: @@ -34,18 +46,14 @@ You can either clone the public repository: $ git clone git://github.com/hetznercloud/hcloud-python -Or download the `tarball`_: +Or download the tarball: .. code-block:: console - $ curl -OL https://github.com/hetznercloud/hcloud-python/tarball/master + $ curl -OL https://github.com/hetznercloud/hcloud-python/tarball/main Once you have a copy of the source, you can install it with: .. code-block:: console - $ python setup.py install - - -.. _Github repo: https://github.com/hetznercloud/hcloud-python -.. _tarball: https://github.com/hetznercloud/hcloud-python/tarball/master + $ pip install . diff --git a/docs/make.bat b/docs/make.bat index 1650e6b8..954237b9 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -5,32 +5,31 @@ pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=python -msphinx + set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build -set SPHINXPROJ=hcloud - -if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. - echo.The Sphinx module was not found. Make sure you have Sphinx installed, - echo.then set the SPHINXBUILD environment variable to point to the full - echo.path of the 'sphinx-build' executable. Alternatively you may add the - echo.Sphinx directory to PATH. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ + echo.https://www.sphinx-doc.org/ exit /b 1 ) -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd diff --git a/docs/samples.rst b/docs/samples.rst deleted file mode 100644 index 603cfc49..00000000 --- a/docs/samples.rst +++ /dev/null @@ -1,69 +0,0 @@ -======== -Samples -======== - -To use Hetzner Cloud Python in a project: - -.. code-block:: python - - from hcloud import Client - from hcloud.images.domain import Image - from hcloud.server_types.domain import ServerType - - # Create a client - client = Client(token="project-token") - - # Create 2 servers - # Create 2 servers - response1 = client.servers.create( - "Server1", - server_type=ServerType(name="cx11"), - image=Image(id=4711) - ) - - response2 = client.servers.create( - "Server2", - server_type=ServerType(name="cx11"), - image=Image(id=4711) - ) - # Get all servers - server1 = response1.server - server2 = response2.server - - servers = client.servers.get_all() - - assert servers[0].id == server1.id - assert servers[1].id == server2.id - # Create 2 volumes - - response1 = client.volumes.create( - size=15, - name="Volume1", - location=server1.location - ) - response2 = client.volumes.create( - size=10, - name="Volume2", - location=server2.location - ) - - volume1 = response1.volume - volume2 = response2.volume - - # Attach volume to server - - volume1.attach(server1) - volume2.attach(server2) - - # Detach second volume - - volume2.detach() - - # Poweroff 2nd server - server2.power_off() - - # Poweroff 2nd server - server2.power_off() - - -More samples are in the repository: https://github.com/hetznercloud/hcloud-python/tree/master/examples. \ No newline at end of file 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 2aaae82f..7aea8fc7 100644 --- a/examples/create_server.py +++ b/examples/create_server.py @@ -1,12 +1,26 @@ +from __future__ import annotations + +from os import environ + from hcloud import Client -from hcloud.images.domain import Image -from hcloud.server_types.domain import ServerType +from hcloud.images import Image +from hcloud.server_types import ServerType + +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_TOKEN"] client = Client( - token="{YOUR_API_TOKEN}" -) # Please paste your API token here between the quotes + 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") + name="my-server", + 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 new file mode 100644 index 00000000..814a815c --- /dev/null +++ b/examples/get_server_metrics.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from os import environ + +from hcloud import Client +from hcloud.images import Image +from hcloud.server_types import ServerType + +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_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(name="cx23"), + image=Image(name="ubuntu-24.04"), + ) + server = response.server + +end = datetime.now(timezone.utc) +start = end - timedelta(hours=1) + +response = server.get_metrics( + type=["cpu", "network"], + start=start, + end=end, +) + +print(response.metrics) diff --git a/examples/list_servers.py b/examples/list_servers.py index 9e34e5c0..64cc10e7 100644 --- a/examples/list_servers.py +++ b/examples/list_servers.py @@ -1,7 +1,18 @@ +from __future__ import annotations + +from os import environ + from hcloud import Client +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_TOKEN"] + client = Client( - token="{YOUR_API_TOKEN}" -) # Please paste your API token here between the quotes + 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 f2460528..f788306d 100644 --- a/examples/usage_oop.py +++ b/examples/usage_oop.py @@ -1,18 +1,31 @@ +from __future__ import annotations + +from os import environ + from hcloud import Client -from hcloud.images.domain import Image -from hcloud.server_types.domain import ServerType +from hcloud.images import Image +from hcloud.server_types import ServerType + +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_TOKEN"] # Create a client -client = Client(token="project-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 b49f7804..6c88cbe7 100644 --- a/examples/usage_procedurale.py +++ b/examples/usage_procedurale.py @@ -1,19 +1,31 @@ -from hcloud import Client +from __future__ import annotations -from hcloud.images.domain import Image -from hcloud.servers.domain import Server -from hcloud.server_types.domain import ServerType -from hcloud.volumes.domain import Volume +from os import environ -client = Client(token="project-token") +from hcloud import Client +from hcloud.images import Image +from hcloud.server_types import ServerType +from hcloud.servers import Server +from hcloud.volumes import Volume + +assert ( + "HCLOUD_TOKEN" in environ +), "Please export your API token in the HCLOUD_TOKEN environment variable" +token = environ["HCLOUD_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 @@ -56,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 ceb1a6c2..8a9fa148 100644 --- a/hcloud/__init__.py +++ b/hcloud/__init__.py @@ -1,3 +1,18 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from .hcloud import Client, APIException # noqa +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 781742db..00000000 --- a/hcloud/__version__.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = "1.16.0" diff --git a/hcloud/_client.py b/hcloud/_client.py new file mode 100644 index 00000000..20f26c9d --- /dev/null +++ b/hcloud/_client.py @@ -0,0 +1,432 @@ +from __future__ import annotations + +import time +from http import HTTPStatus +from random import uniform +from typing import Any, Protocol + +import requests + +from ._exceptions import APIException +from ._version import __version__ +from .actions import ActionsClient +from .certificates import CertificatesClient +from .datacenters import DatacentersClient +from .firewalls import FirewallsClient +from .floating_ips import FloatingIPsClient +from .images import ImagesClient +from .isos import IsosClient +from .load_balancer_types import LoadBalancerTypesClient +from .load_balancers import LoadBalancersClient +from .locations import LocationsClient +from .networks import NetworksClient +from .placement_groups import PlacementGroupsClient +from .primary_ips import PrimaryIPsClient +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: + """ + 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: + + - ``conflict`` + - ``rate_limit_exceeded`` + - ``timeout`` + + Changes to the retry policy might occur between releases, and will not be considered + breaking changes. + """ + + def __init__( + self, + token: str, + api_endpoint: str = "https://api.hetzner.cloud/v1", + 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, + *, + 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 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._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 + + :type: :class:`DatacentersClient ` + """ + self.locations = LocationsClient(self) + """LocationsClient Instance + + :type: :class:`LocationsClient ` + """ + self.servers = ServersClient(self) + """ServersClient Instance + + :type: :class:`ServersClient ` + """ + self.server_types = ServerTypesClient(self) + """ServerTypesClient Instance + + :type: :class:`ServerTypesClient ` + """ + self.volumes = VolumesClient(self) + """VolumesClient Instance + + :type: :class:`VolumesClient ` + """ + self.actions = ActionsClient(self) + """ActionsClient Instance + + :type: :class:`ActionsClient ` + """ + self.images = ImagesClient(self) + """ImagesClient Instance + + :type: :class:`ImagesClient ` + """ + self.isos = IsosClient(self) + """ImagesClient Instance + + :type: :class:`IsosClient ` + """ + self.ssh_keys = SSHKeysClient(self) + """SSHKeysClient Instance + + :type: :class:`SSHKeysClient ` + """ + self.floating_ips = FloatingIPsClient(self) + """FloatingIPsClient Instance + + :type: :class:`FloatingIPsClient ` + """ + self.primary_ips = PrimaryIPsClient(self) + """PrimaryIPsClient Instance + + :type: :class:`PrimaryIPsClient ` + """ + self.networks = NetworksClient(self) + """NetworksClient Instance + + :type: :class:`NetworksClient ` + """ + self.certificates = CertificatesClient(self) + """CertificatesClient Instance + + :type: :class:`CertificatesClient ` + """ + + self.load_balancers = LoadBalancersClient(self) + """LoadBalancersClient Instance + + :type: :class:`LoadBalancersClient ` + """ + + self.load_balancer_types = LoadBalancerTypesClient(self) + """LoadBalancerTypesClient Instance + + :type: :class:`LoadBalancerTypesClient ` + """ + + self.firewalls = FirewallsClient(self) + """FirewallsClient Instance + + :type: :class:`FirewallsClient ` + """ + + self.placement_groups = PlacementGroupsClient(self) + """PlacementGroupsClient Instance + + :type: :class:`PlacementGroupsClient ` + """ + + self.zones = ZonesClient(self) + """ZonesClient Instance + + :type: :class:`ZonesClient ` + """ + + self.storage_box_types = StorageBoxTypesClient(self) + """StorageBoxTypesClient Instance + + :type: :class:`StorageBoxTypesClient ` + """ + + self.storage_boxes = StorageBoxesClient(self) + """StorageBoxesClient Instance + + :type: :class:`StorageBoxesClient ` + """ + + def request( # type: ignore[no-untyped-def] + self, + method: str, + url: str, + **kwargs, + ) -> dict[str, Any]: + """Perform a request to the Hetzner Cloud API. + + :param method: Method to perform the request. + :param url: URL to perform the request. + :param timeout: Requests timeout in seconds. + """ + return self._client.request(method, url, **kwargs) + + +class ClientBase: + def __init__( + self, + token: str, + *, + endpoint: str, + application_name: str | None = None, + application_version: str | None = None, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, + timeout: float | tuple[float, float] | None = None, + ): + self._token = token + self._endpoint = endpoint + + self._user_agent = _build_user_agent(application_name, application_version) + self._headers = { + "User-Agent": self._user_agent, + "Authorization": f"Bearer {self._token}", + "Accept": "application/json", + } + + if isinstance(poll_interval, (int, float)): + poll_interval_func = constant_backoff_function(poll_interval) + else: + poll_interval_func = poll_interval + + self._poll_interval_func = poll_interval_func + self._poll_max_retries = poll_max_retries + + self._retry_interval_func = exponential_backoff_function( + base=1.0, multiplier=2, cap=60.0, jitter=True + ) + self._retry_max_retries = 5 + + self._timeout = timeout + self._session = requests.Session() + + def request( # type: ignore[no-untyped-def] + self, + 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(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 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", + "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 new file mode 100644 index 00000000..e33105a3 --- /dev/null +++ b/hcloud/_exceptions.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + + +class HCloudException(Exception): + """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.""" + + 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)})" + + 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..aa056a4a --- /dev/null +++ b/hcloud/_version.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__version__ = "2.16.0" # x-releaser-pleaser-version diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index 40a96afc..ac677936 100644 --- a/hcloud/actions/__init__.py +++ b/hcloud/actions/__init__.py @@ -1 +1,33 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + ActionsClient, + ActionSort, + ActionsPageResult, + BoundAction, + ResourceActionsClient, +) +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 70fa0bbf..176a6b18 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -1,70 +1,107 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + import time +import warnings +from typing import TYPE_CHECKING, Any, Literal, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import Action, ActionFailedException, ActionStatus, ActionTimeoutException + +if TYPE_CHECKING: + from .._client import Client + -from hcloud.core.client import ClientEntityBase, BoundModelBase -from hcloud.actions.domain import Action, ActionFailedException, ActionTimeoutException +__all__ = [ + "ActionsClient", + "ActionsPageResult", + "BoundAction", + "ResourceActionsClient", + "ActionSort", +] -class BoundAction(BoundModelBase): +class BoundAction(BoundModelBase[Action], Action): + _client: ActionsClient + model = Action - def wait_until_finished(self, max_retries=100): - """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() - time.sleep(self._client._client.poll_interval) - max_retries = max_retries - 1 - else: - raise ActionTimeoutException(action=self) + if max_retries is None: + # pylint: disable=protected-access + max_retries = self._client._client._poll_max_retries - if self.status == Action.STATUS_ERROR: - raise ActionFailedException(action=self) + 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_func(retries)) + continue -class ActionsClient(ClientEntityBase): - results_list_attribute_name = "actions" + raise ActionTimeoutException(action=self) - def get_by_id(self, id): - # type: (int) -> BoundAction - """Get a specific action by its ID. + if self.status == Action.STATUS_ERROR: + raise ActionFailedException(action=self) - :param id: int - :return: :class:`BoundAction ` - """ +ActionSort = Literal[ + "id", + "id:asc", + "id:desc", + "command", + "command:asc", + "command:desc", + "status", + "status:asc", + "status:desc", + "started", + "started:asc", + "started:desc", + "finished", + "finished:asc", + "finished:desc", +] + + +class ActionsPageResult(NamedTuple): + actions: list[BoundAction] + meta: Meta + + +class ResourceClientBaseActionsMixin(ResourceClientBase): + def _get_action_by_id( + self, + base_url: str, + id: int, + ) -> BoundAction: response = self._client.request( - url="/actions/{action_id}".format(action_id=id), method="GET" + method="GET", + url=f"{base_url}/actions/{id}", + ) + return BoundAction( + client=self._parent.actions, + data=response["action"], ) - return BoundAction(self, response["action"]) - def get_list( + def _get_actions_list( self, - status=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundAction]] - """Get a list of actions from this account - - :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 = {} + base_url: str, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + params: dict[str, Any] = {} if status is not None: params["status"] = status if sort is not None: @@ -74,20 +111,124 @@ def get_list( if per_page is not None: params["per_page"] = per_page - response = self._client.request(url="/actions", method="GET", params=params) - actions = [ - BoundAction(self, action_data) for action_data in response["actions"] - ] - return self._add_meta_to_result(actions, response) - - def get_all(self, status=None, sort=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Get all actions of the account - - :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 `] + response = self._client.request( + method="GET", + url=f"{base_url}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + +class ResourceActionsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _resource: str + + def __init__(self, client: ResourceClientBase | Client, resource: str | None): + 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 super(ActionsClient, self).get_all(status=status, sort=sort) + 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[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions. + + :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) + + +class ActionsClient(ResourceActionsClient): + def __init__(self, client: Client): + super().__init__(client, None) + + def get_list( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + .. deprecated:: 1.28 + Use :func:`client..actions.get_list` instead, + e.g. using :attr:`hcloud.certificates.client.CertificatesClient.actions`. + + `Starting 1 October 2023, it will no longer be available. `_ + """ + warnings.warn( + "The 'client.actions.get_list' method is deprecated, please use the " + "'client..actions.get_list' method instead (e.g. " + "'client.certificates.actions.get_list').", + DeprecationWarning, + stacklevel=2, + ) + return super().get_list(status=status, sort=sort, page=page, per_page=per_page) + + def get_all( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + .. deprecated:: 1.28 + Use :func:`client..actions.get_all` instead, + e.g. using :attr:`hcloud.certificates.client.CertificatesClient.actions`. + + `Starting 1 October 2023, it will no longer be available. `_ + """ + warnings.warn( + "The 'client.actions.get_all' method is deprecated, please use the " + "'client..actions.get_all' method instead (e.g. " + "'client.certificates.actions.get_all').", + DeprecationWarning, + stacklevel=2, + ) + return super().get_all(status=status, sort=sort) diff --git a/hcloud/actions/domain.py b/hcloud/actions/domain.py index e40c62a7..59b7f442 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -1,7 +1,28 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from .._exceptions import HCloudException +from ..core import BaseDomain + +if TYPE_CHECKING: + from .client import BoundAction + +__all__ = [ + "ActionStatus", + "Action", + "ActionResource", + "ActionError", + "ActionException", + "ActionFailedException", + "ActionTimeoutException", +] + +ActionStatus = Literal[ + "running", + "success", + "error", +] class Action(BaseDomain): @@ -24,7 +45,7 @@ class Action(BaseDomain): STATUS_ERROR = "error" """Action Status error""" - __slots__ = ( + __api_properties__ = ( "id", "command", "status", @@ -34,38 +55,72 @@ class Action(BaseDomain): "started", "finished", ) + __slots__ = __api_properties__ def __init__( self, - id, - command=None, - status=None, - progress=None, - started=None, - finished=None, - resources=None, - error=None, + id: int, + command: str | None = None, + status: ActionStatus | None = None, + progress: int | None = None, + started: str | None = None, + finished: str | 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 ActionFailedException(Exception): - """The Action you was waiting for failed""" +class ActionResource(TypedDict): + id: int + type: str + + +class ActionError(TypedDict): + code: str + message: str + details: dict[str, Any] - def __init__(self, action): - self.action = action +class ActionException(HCloudException): + """A generic action exception""" -class ActionTimeoutException(Exception): - """The Action you was waiting for timeouted in hcloud-python.""" + def __init__(self, action: Action | BoundAction): + assert self.__doc__ is not None + message = self.__doc__ - def __init__(self, action): + 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 + + +class ActionFailedException(ActionException): + """The pending action failed""" + + +class ActionTimeoutException(ActionException): + """The pending action timed out""" diff --git a/hcloud/certificates/__init__.py b/hcloud/certificates/__init__.py index 40a96afc..5b85358e 100644 --- a/hcloud/certificates/__init__.py +++ b/hcloud/certificates/__init__.py @@ -1 +1,23 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundCertificate, + CertificatesClient, + CertificatesPageResult, +) +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 d94e5507..46a6a538 100644 --- a/hcloud/certificates/client.py +++ b/hcloud/certificates/client.py @@ -1,20 +1,45 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.certificates.domain import ( +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 ( Certificate, CreateManagedCertificateResponse, - ManagedCertificateStatus, ManagedCertificateError, + ManagedCertificateStatus, ) -from hcloud.core.domain import add_meta_to_result + +if TYPE_CHECKING: + from .._client import Client -class BoundCertificate(BoundModelBase): +__all__ = [ + "BoundCertificate", + "CertificatesPageResult", + "CertificatesClient", +] + + +class BoundCertificate(BoundModelBase[Certificate], Certificate): + _client: CertificatesClient + model = Certificate - def __init__(self, client, data, complete=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") @@ -26,86 +51,117 @@ def __init__(self, client, data, complete=True): data["status"] = ManagedCertificateStatus( issuance=status["issuance"], renewal=status["renewal"], error=error ) - super(BoundCertificate, self).__init__(client, data, complete) + super().__init__(client, data, complete) - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a Certificate. + 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 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=None, sort=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Certificate. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundCertificate + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundCertificate: """Updates an certificate. You can update an certificate name and the certificate labels. :param name: str (optional) New name to set :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) - :return: :class:`BoundCertificate + :return: :class:`BoundCertificate ` """ - return self._client.update(self, name, labels) + return self._client.update( + self, + name=name, + labels=labels, + ) - def delete(self): - # type: () -> bool + def delete(self) -> bool: """Deletes a certificate. :return: boolean """ return self._client.delete(self) - def retry_issuance(self): - # type: () -> BoundAction + def retry_issuance(self) -> BoundAction: """Retry a failed Certificate issuance or renewal. :return: BoundAction """ return self._client.retry_issuance(self) -class CertificatesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "certificates" +class CertificatesPageResult(NamedTuple): + certificates: list[BoundCertificate] + meta: Meta + - def get_by_id(self, id): - # type: (int) -> BoundCertificate +class CertificatesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/certificates" + + actions: ResourceActionsClient + """Certificates scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_by_id(self, id: int) -> BoundCertificate: """Get a specific certificate by its ID. :param id: int :return: :class:`BoundCertificate ` """ - response = self._client.request( - url="/certificates/{certificate_id}".format(certificate_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundCertificate(self, response["certificate"]) def get_list( self, - name=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundCertificate], Meta] + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> CertificatesPageResult: """Get a list of certificates :param name: str (optional) @@ -118,7 +174,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundCertificate `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name @@ -131,19 +187,20 @@ 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) for certificate_data in response["certificates"] ] - return self._add_meta_to_result(certificates, response) + return CertificatesPageResult(certificates, Meta.parse_meta(response)) - def get_all(self, name=None, label_selector=None): - # type: (Optional[str]) -> List[BoundCertificate] + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + ) -> list[BoundCertificate]: """Get all certificates :param name: str (optional) @@ -152,22 +209,24 @@ def get_all(self, name=None, label_selector=None): Can be used to filter certificates by labels. The response will only contain certificates matching the label selector. :return: List[:class:`BoundCertificate `] """ - return super(CertificatesClient, self).get_all( - name=name, label_selector=label_selector - ) + return self._iter_pages(self.get_list, name=name, label_selector=label_selector) - def get_by_name(self, name): - # type: (str) -> BoundCertificate + def get_by_name(self, name: str) -> BoundCertificate | None: """Get certificate by name :param name: str Used to get certificate by name. :return: :class:`BoundCertificate ` """ - return super(CertificatesClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) - def create(self, name, certificate, private_key, labels=None): - # type: (str, str, Optional[Dict[str, str]]) -> BoundCertificate + def create( + self, + name: str, + certificate: str, + private_key: str, + labels: dict[str, str] | None = None, + ) -> BoundCertificate: """Creates a new Certificate with the given name, certificate and private_key. This methods allows only creating custom uploaded certificates. If you want to create a managed certificate use :func:`~hcloud.certificates.client.CertificatesClient.create_managed` @@ -180,7 +239,7 @@ def create(self, name, certificate, private_key, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundCertificate ` """ - data = { + data: dict[str, Any] = { "name": name, "certificate": certificate, "private_key": private_key, @@ -188,11 +247,15 @@ def create(self, name, certificate, private_key, labels=None): } 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(self, name, domain_names, labels=None): - # type: (str, List[str], Optional[Dict[str, str]]) -> CreateManagedCertificateResponse + def create_managed( + self, + name: str, + domain_names: list[str], + labels: dict[str, str] | None = None, + ) -> CreateManagedCertificateResponse: """Creates a new managed Certificate with the given name and domain names. This methods allows only creating managed certificates for domains that are using the Hetzner DNS service. If you want to create a custom uploaded certificate use :func:`~hcloud.certificates.client.CertificatesClient.create` @@ -203,21 +266,25 @@ def create_managed(self, name, domain_names, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundCertificate ` """ - data = { + data: dict[str, Any] = { "name": name, "type": Certificate.TYPE_MANAGED, "domain_names": domain_names, } 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(self, certificate, name=None, labels=None): - # type: (Certificate, Optional[str], Optional[Dict[str, str]]) -> BoundCertificate + def update( + self, + certificate: Certificate | BoundCertificate, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundCertificate: """Updates a Certificate. You can update a certificate name and labels. :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` @@ -227,98 +294,87 @@ def update(self, certificate, name=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundCertificate ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data["name"] = name if labels is not None: data["labels"] = labels response = self._client.request( - url="/certificates/{certificate_id}".format(certificate_id=certificate.id), + url=f"{self._base_url}/{certificate.id}", method="PUT", json=data, ) return BoundCertificate(self, response["certificate"]) - def delete(self, certificate): - # type: (Certificate) -> bool - self._client.request( - url="/certificates/{certificate_id}".format(certificate_id=certificate.id), - method="DELETE", - ) + def delete(self, certificate: Certificate | BoundCertificate) -> bool: """Deletes a certificate. :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` :return: True """ + self._client.request( + 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 return True def get_actions_list( - self, certificate, status=None, sort=None, page=None, per_page=None - ): - # type: (Certificate, Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] - """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 `) + self, + certificate: Certificate | BoundCertificate, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: """ - params = {} - 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="/certificates/{certificate_id}/actions".format( - certificate_id=certificate.id - ), - 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 add_meta_to_result(actions, response, "actions") - def get_actions(self, certificate, status=None, sort=None): - # type: (Certificate, Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Certificate. + def get_actions( + self, + certificate: Certificate | BoundCertificate, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(CertificatesClient, self).get_actions( - certificate, status=status, sort=sort + return self._iter_pages( + self.get_actions_list, + certificate, + status=status, + sort=sort, ) - def retry_issuance(self, certificate): - # type: (Certificate) -> BoundAction + def retry_issuance( + self, + certificate: Certificate | BoundCertificate, + ) -> BoundAction: """Returns all action objects for a Certificate. :param certificate: :class:`BoundCertificate ` or :class:`Certificate ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/certificates/{certificate_id}/actions/retry".format( - certificate_id=certificate.id - ), + 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 bde10fea..677ab8f2 100644 --- a/hcloud/certificates/domain.py +++ b/hcloud/certificates/domain.py @@ -1,7 +1,20 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from typing import TYPE_CHECKING + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundCertificate + + +__all__ = [ + "Certificate", + "ManagedCertificateStatus", + "ManagedCertificateError", + "CreateManagedCertificateResponse", +] class Certificate(BaseDomain, DomainIdentityMixin): @@ -24,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", @@ -37,22 +50,24 @@ class Certificate(BaseDomain, DomainIdentityMixin): "type", "status", ) + __slots__ = __api_properties__ + TYPE_UPLOADED = "uploaded" TYPE_MANAGED = "managed" def __init__( self, - id=None, - name=None, - certificate=None, - not_valid_before=None, - not_valid_after=None, - domain_names=None, - fingerprint=None, - created=None, - labels=None, - type=None, - status=None, + id: int | None = None, + name: str | None = None, + certificate: str | None = None, + not_valid_before: str | None = None, + not_valid_after: str | None = None, + domain_names: list[str] | None = None, + fingerprint: str | None = None, + created: str | None = None, + labels: dict[str, str] | None = None, + type: str | None = None, + status: ManagedCertificateStatus | None = None, ): self.id = id self.name = name @@ -60,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 @@ -78,7 +93,19 @@ class ManagedCertificateStatus(BaseDomain): If issuance or renewal reports failure, this property contains information about what happened """ - def __init__(self, issuance=None, renewal=None, error=None): + __api_properties__ = ( + "issuance", + "renewal", + "error", + ) + __slots__ = __api_properties__ + + def __init__( + self, + issuance: str | None = None, + renewal: str | None = None, + error: ManagedCertificateError | None = None, + ): self.issuance = issuance self.renewal = renewal self.error = error @@ -93,7 +120,13 @@ class ManagedCertificateError(BaseDomain): Message detailing the error """ - def __init__(self, code=None, message=None): + __api_properties__ = ( + "code", + "message", + ) + __slots__ = __api_properties__ + + def __init__(self, code: str | None = None, message: str | None = None): self.code = code self.message = message @@ -107,15 +140,13 @@ class CreateManagedCertificateResponse(BaseDomain): Shows the progress of the certificate creation """ - __slots__ = ( - "certificate", - "action", - ) + __api_properties__ = ("certificate", "action") + __slots__ = __api_properties__ def __init__( self, - certificate, # type: BoundCertificate - action, # type: BoundAction + certificate: BoundCertificate, + action: BoundAction, ): self.certificate = certificate self.action = action diff --git a/hcloud/core/__init__.py b/hcloud/core/__init__.py index 40a96afc..4fb66e86 100644 --- a/hcloud/core/__init__.py +++ b/hcloud/core/__init__.py @@ -1 +1,14 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 f024c9be..4ff7f240 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,102 +1,104 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import add_meta_to_result +from __future__ import annotations +import warnings +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar -class ClientEntityBase(object): - max_per_page = 50 - results_list_attribute_name = None +from .domain import BaseDomain - def __init__(self, client): - """ - :param client: Client - :return self - """ - self._client = client +if TYPE_CHECKING: + from .._client import Client, ClientBase + from .domain import Meta - def _is_list_attribute_implemented(self): - if self.results_list_attribute_name is None: - raise NotImplementedError( - "in order to get results list, 'results_list_attribute_name' attribute of {} has to be specified".format( - self.__class__.__name__ - ) - ) +__all__ = [ + "ResourceClientBase", + "ClientEntityBase", + "BoundModelBase", +] + + +T = TypeVar("T") - def _add_meta_to_result( - self, - results, # type: List[BoundModelBase] - response, # type: json - ): - # type: (...) -> PageResult - self._is_list_attribute_implemented() - return add_meta_to_result(results, response, self.results_list_attribute_name) - def _get_all( +class ResourceClientBase: + _base_url: ClassVar[str] + _parent: Client + _client: ClientBase + + max_per_page: int = 50 + + def __init__(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, # type: function - results_list_attribute_name, # type: str + list_function: Callable[..., tuple[list[T], Meta]], *args, - **kwargs - ): - # type (...) -> List[BoundModelBase] - page = 1 - + **kwargs, + ) -> list[T]: results = [] + page = 1 while page: - page_result = list_function( - page=page, per_page=self.max_per_page, *args, **kwargs + # The *PageResult tuples MUST have the following structure + # `(result: List[Bound*], meta: Meta)` + result, meta = list_function( + *args, page=page, per_page=self.max_per_page, **kwargs ) - result = getattr(page_result, results_list_attribute_name) if result: results.extend(result) - meta = page_result.meta - if ( - meta - and meta.pagination - and meta.pagination.next_page - and meta.pagination.next_page - ): + + if meta and meta.pagination and meta.pagination.next_page: page = meta.pagination.next_page else: - page = None + page = 0 return results - def get_all(self, *args, **kwargs): - # type: (...) -> List[BoundModelBase] - self._is_list_attribute_implemented() - return self._get_all( - self.get_list, self.results_list_attribute_name, *args, **kwargs - ) - - def get_actions(self, *args, **kwargs): - # type: (...) -> List[BoundModelBase] - if not hasattr(self, "get_actions_list"): - raise ValueError("this endpoint does not support get_actions method") - - return self._get_all(self.get_actions_list, "actions", *args, **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 GetEntityByNameMixin(object): +class ClientEntityBase(ResourceClientBase): """ - Use as a mixin for ClientEntityBase classes + Kept for backward compatibility. + + .. deprecated:: 2.6.0 + Use :class:``hcloud.core.client.ResourceClientBase`` instead. """ - def get_by_name(self, name): - # type: (str) -> BoundModelBase - self._is_list_attribute_implemented() - response = self.get_list(name=name) - entities = getattr(response, self.results_list_attribute_name) - entity = entities[0] if entities else None - return entity + 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(object): + +class BoundModelBase(Generic[Domain]): """Bound Model Base""" - model = None + model: type[Domain] - def __init__(self, client, data={}, complete=True): + def __init__( + self, + client: ResourceClientBase, + data: dict[str, Any], + complete: bool = True, + ): """ :param client: The client for the specific model to use @@ -107,9 +109,9 @@ def __init__(self, client, data={}, complete=True): """ self._client = client self.complete = complete - self.data_model = self.model.from_dict(data) + self.data_model: Domain = self.model.from_dict(data) - def __getattr__(self, name): + def __getattr__(self, name: str): # type: ignore[no-untyped-def] """Allow magical access to the properties of the model :param name: str :return: @@ -120,8 +122,25 @@ def __getattr__(self, name): value = getattr(self.data_model, name) return value - def reload(self): - """Reloads the model and tries to get all data from the APIx""" - bound_model = self._client.get_by_id(self.data_model.id) + def _get_self(self) -> BoundModelBase[Domain]: + assert hasattr(self._client, "get_by_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 + + def __repr__(self) -> str: + # Override and reset hcloud.core.domain.BaseDomain.__repr__ method for bound + # 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 1703ae2f..6659df80 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,31 +1,97 @@ -# -*- coding: utf-8 -*- -from collections import namedtuple +from __future__ import annotations +from datetime import datetime +from typing import Any, overload -class BaseDomain(object): - __slots__ = () +from dateutil.parser import isoparse + +__all__ = [ + "BaseDomain", + "DomainIdentityMixin", + "Pagination", + "Meta", +] + + +class BaseDomain: + __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data): - supported_data = {k: v for k, v in data.items() if k in cls.__slots__} + 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.__api_properties__} return cls(**supported_data) + def __repr__(self) -> str: + 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(object): - __slots__ = () + +class DomainIdentityMixin: + + id: int | None + name: str | None @property - def id_or_name(self): + def id_or_name(self) -> int | str: + """ + Return the first defined value, and fails if none is defined. + """ if self.id is not None: return self.id - elif self.name is not None: + if self.name is not None: return self.name - else: + raise ValueError("id or name must be set") + + def has_id_or_name(self, id_or_name: int | str) -> bool: + """ + Return whether this domain has the same id or same name as the other. + + The domain calling this method MUST be a bound domain or be populated, otherwise + 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). + """ + result = None + + if self.id is not None: + 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: + result = result or self.name == str(id_or_name) + + if result is None: raise ValueError("id or name must be set") + return result + class Pagination(BaseDomain): - __slots__ = ( + __api_properties__ = ( "page", "per_page", "previous_page", @@ -33,15 +99,16 @@ class Pagination(BaseDomain): "last_page", "total_entries", ) + __slots__ = __api_properties__ def __init__( self, - page, - per_page, - previous_page=None, - next_page=None, - last_page=None, - total_entries=None, + page: int, + per_page: int, + previous_page: int | None = None, + next_page: int | None = None, + last_page: int | None = None, + total_entries: int | None = None, ): self.page = page self.per_page = per_page @@ -52,29 +119,22 @@ def __init__( class Meta(BaseDomain): + __api_properties__ = ("pagination",) + __slots__ = __api_properties__ - __slots__ = ("pagination",) - - def __init__( - self, - pagination=None, - ): + def __init__(self, pagination: Pagination | None = None): self.pagination = pagination @classmethod - def parse_meta(cls, json_content): - meta = None - if json_content and "meta" in json_content: - meta = cls() - pagination_json = json_content["meta"].get("pagination") - if pagination_json: - pagination = Pagination(**pagination_json) - meta.pagination = pagination - return meta + def parse_meta(cls, response: dict[str, Any]) -> Meta: + """ + If present, extract the meta details from the response and return a meta object. + """ + meta = cls() + if response and "meta" in response: + try: + meta.pagination = Pagination(**response["meta"]["pagination"]) + except KeyError: + pass - -def add_meta_to_result(result, json_content, attr_name): - # type: (List[BoundModelBase], json, string) -> PageResult - class_name = "PageResults{0}".format(attr_name.capitalize()) - PageResults = namedtuple(class_name, [attr_name, "meta"]) - return PageResults(**{attr_name: result, "meta": Meta.parse_meta(json_content)}) + return meta diff --git a/hcloud/datacenters/__init__.py b/hcloud/datacenters/__init__.py index 40a96afc..fdfbb189 100644 --- a/hcloud/datacenters/__init__.py +++ b/hcloud/datacenters/__init__.py @@ -1 +1,16 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundDatacenter, + DatacentersClient, + DatacentersPageResult, +) +from .domain import Datacenter, DatacenterServerTypes + +__all__ = [ + "BoundDatacenter", + "Datacenter", + "DatacenterServerTypes", + "DatacentersClient", + "DatacentersPageResult", +] diff --git a/hcloud/datacenters/client.py b/hcloud/datacenters/client.py index 71548900..5d7fe964 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,36 +1,46 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.datacenters.domain import Datacenter, DatacenterServerTypes -from hcloud.locations.client import BoundLocation -from hcloud.server_types.client import BoundServerType +from typing import Any, NamedTuple +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation +from ..server_types import BoundServerType +from .domain import Datacenter, DatacenterServerTypes + +__all__ = [ + "BoundDatacenter", + "DatacentersPageResult", + "DatacentersClient", +] + + +class BoundDatacenter(BoundModelBase[Datacenter], Datacenter): + _client: DatacentersClient -class BoundDatacenter(BoundModelBase): model = Datacenter - def __init__(self, client, data): + 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"] ] @@ -40,31 +50,32 @@ def __init__(self, client, data): available_for_migration=available_for_migration, ) - super(BoundDatacenter, self).__init__(client, data) + super().__init__(client, data) + + +class DatacentersPageResult(NamedTuple): + datacenters: list[BoundDatacenter] + meta: Meta -class DatacentersClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "datacenters" +class DatacentersClient(ResourceClientBase): + _base_url = "/datacenters" - def get_by_id(self, id): - # type: (int) -> BoundDatacenter + def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. :param id: int :return: :class:`BoundDatacenter ` """ - response = self._client.request( - url="/datacenters/{datacenter_id}".format(datacenter_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundDatacenter(self, response["datacenter"]) def get_list( self, - name=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundDatacenter], Meta] + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> DatacentersPageResult: """Get a list of datacenters :param name: str (optional) @@ -75,7 +86,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundDatacenter `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name @@ -85,31 +96,29 @@ 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) for datacenter_data in response["datacenters"] ] - return self._add_meta_to_result(datacenters, response) + return DatacentersPageResult(datacenters, Meta.parse_meta(response)) - def get_all(self, name=None): - # type: (Optional[str]) -> List[BoundDatacenter] + def get_all(self, name: str | None = None) -> list[BoundDatacenter]: """Get all datacenters :param name: str (optional) Can be used to filter datacenters by their name. :return: List[:class:`BoundDatacenter `] """ - return super(DatacentersClient, self).get_all(name=name) + return self._iter_pages(self.get_list, name=name) - def get_by_name(self, name): - # type: (str) -> BoundDatacenter + def get_by_name(self, name: str) -> BoundDatacenter | None: """Get datacenter by name :param name: str Used to get datacenter by name. :return: :class:`BoundDatacenter ` """ - return super(DatacentersClient, self).get_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 1335adca..4867915d 100644 --- a/hcloud/datacenters/domain.py +++ b/hcloud/datacenters/domain.py @@ -1,5 +1,17 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..locations import Location + from ..server_types import BoundServerType + +__all__ = [ + "Datacenter", + "DatacenterServerTypes", +] class Datacenter(BaseDomain, DomainIdentityMixin): @@ -12,16 +24,16 @@ class Datacenter(BaseDomain, DomainIdentityMixin): :param server_types: :class:`DatacenterServerTypes ` """ - __slots__ = ( - "id", - "name", - "description", - "location", - "server_types", - ) + __api_properties__ = ("id", "name", "description", "location", "server_types") + __slots__ = __api_properties__ def __init__( - self, id=None, name=None, description=None, location=None, server_types=None + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + location: Location | None = None, + server_types: DatacenterServerTypes | None = None, ): self.id = id self.name = name @@ -30,7 +42,7 @@ def __init__( self.server_types = server_types -class DatacenterServerTypes: +class DatacenterServerTypes(BaseDomain): """DatacenterServerTypes Domain :param available: List[:class:`BoundServerTypes `] @@ -41,9 +53,15 @@ class DatacenterServerTypes: 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, available, supported, available_for_migration): + def __init__( + self, + available: list[BoundServerType], + supported: list[BoundServerType], + available_for_migration: list[BoundServerType], + ): self.available = available self.supported = supported self.available_for_migration = available_for_migration diff --git a/hcloud/deprecation/__init__.py b/hcloud/deprecation/__init__.py new file mode 100644 index 00000000..4dd0e91f --- /dev/null +++ b/hcloud/deprecation/__init__.py @@ -0,0 +1,7 @@ +from __future__ import annotations + +from .domain import DeprecationInfo + +__all__ = [ + "DeprecationInfo", +] diff --git a/hcloud/deprecation/domain.py b/hcloud/deprecation/domain.py new file mode 100644 index 00000000..c78c43e6 --- /dev/null +++ b/hcloud/deprecation/domain.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +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 + deprecated. If it has a value, it is considered deprecated. + + :param announced: datetime + Date of when the deprecation was announced. + :param unavailable_after: datetime + After the time in this field, the resource will not be available from the general listing endpoint of the + resource type, and it can not be used in new resources. For example, if this is an image, you can not create + new servers with this image after the mentioned date. + """ + + __api_properties__ = ( + "announced", + "unavailable_after", + ) + __slots__ = __api_properties__ + + def __init__( + self, + announced: str | None = None, + unavailable_after: str | None = 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 40a96afc..f81ae0df 100644 --- a/hcloud/firewalls/__init__.py +++ b/hcloud/firewalls/__init__.py @@ -1 +1,23 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import BoundFirewall, FirewallsClient, FirewallsPageResult +from .domain import ( + CreateFirewallResponse, + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + 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 76797732..f314b68a 100644 --- a/hcloud/firewalls/client.py +++ b/hcloud/firewalls/client.py @@ -1,21 +1,47 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result +from __future__ import annotations -from hcloud.firewalls.domain import ( - Firewall, +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 ( CreateFirewallResponse, - FirewallRule, + Firewall, FirewallResource, + FirewallResourceAppliedToResources, FirewallResourceLabelSelector, + FirewallRule, ) +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundFirewall", + "FirewallsPageResult", + "FirewallsClient", +] + + +class BoundFirewall(BoundModelBase[Firewall], Firewall): + _client: FirewallsClient -class BoundFirewall(BoundModelBase): model = Firewall - def __init__(self, client, data, complete=True): + def __init__( + self, + client: FirewallsClient, + data: dict[str, Any], + complete: bool = True, + ): rules = data.get("rules", []) if rules: rules = [ @@ -33,63 +59,101 @@ def __init__(self, client, data, complete=True): applied_to = data.get("applied_to", []) if applied_to: - from hcloud.servers.client import BoundServer + # pylint: disable=import-outside-toplevel + from ..servers import BoundServer + + data_applied_to = [] + for firewall_resource in applied_to: + applied_to_resources = None + if firewall_resource.get("applied_to_resources"): + applied_to_resources = [ + FirewallResourceAppliedToResources( + type=resource["type"], + server=( + BoundServer( + client._parent.servers, + resource.get("server"), + complete=False, + ) + if resource.get("server") is not None + else None + ), + ) + for resource in firewall_resource.get("applied_to_resources") + ] - ats = [] - for a in applied_to: - if a["type"] == FirewallResource.TYPE_SERVER: - ats.append( + if firewall_resource["type"] == FirewallResource.TYPE_SERVER: + data_applied_to.append( FirewallResource( - type=a["type"], + type=firewall_resource["type"], server=BoundServer( - client._client.servers, a["server"], complete=False + client._parent.servers, + firewall_resource["server"], + complete=False, ), + applied_to_resources=applied_to_resources, ) ) - elif a["type"] == FirewallResource.TYPE_LABEL_SELECTOR: - ats.append( + elif firewall_resource["type"] == FirewallResource.TYPE_LABEL_SELECTOR: + data_applied_to.append( FirewallResource( - type=a["type"], + type=firewall_resource["type"], label_selector=FirewallResourceLabelSelector( - selector=a["label_selector"]["selector"] + selector=firewall_resource["label_selector"]["selector"] ), + applied_to_resources=applied_to_resources, ) ) - data["applied_to"] = ats - super(BoundFirewall, self).__init__(client, data, complete) + data["applied_to"] = data_applied_to - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResult[BoundAction, Meta] - """Returns all action objects for a Firewall. + super().__init__(client, data, complete) - :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 `) + 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: """ - return self._client.get_actions_list(self, status, sort, page, per_page) + Returns a paginated list of Actions for a Firewall. - def get_actions(self, status=None, sort=None): - # type: (Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Firewall. + :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, + ) - :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` + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]], Optional[str]) -> BoundFirewall + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundFirewall: """Updates the name or labels of a Firewall. :param labels: Dict[str, str] (optional) @@ -98,129 +162,127 @@ def update(self, name=None, labels=None): 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): - # type: () -> bool + def delete(self) -> bool: """Deletes a Firewall. :return: boolean """ return self._client.delete(self) - def set_rules(self, rules): - # type: (List[FirewallRule]) -> List[BoundAction] + def set_rules(self, rules: list[FirewallRule]) -> list[BoundAction]: """Sets the rules of a Firewall. All existing rules will be overwritten. Pass an empty rules array to remove all rules. :param rules: List[:class:`FirewallRule `] :return: List[:class:`BoundAction `] """ - return self._client.set_rules(self, rules) + return self._client.set_rules(self, rules=rules) - def apply_to_resources(self, resources): - # type: (List[FirewallResource]) -> List[BoundAction] + def apply_to_resources( + self, + resources: list[FirewallResource], + ) -> list[BoundAction]: """Applies one Firewall to multiple 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, resources): - # type: (List[FirewallResource]) -> List[BoundAction] + def remove_from_resources( + self, + resources: list[FirewallResource], + ) -> list[BoundAction]: """Removes one Firewall from multiple 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 -class FirewallsClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "firewalls" +class FirewallsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/firewalls" + + actions: ResourceActionsClient + """Firewalls scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, - firewall, # type: Firewall - status=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundAction], Meta] - """Returns all action objects for a Firewall. + firewall: Firewall | BoundFirewall, + 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 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 = {} - 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="/firewalls/{firewall_id}/actions".format(firewall_id=firewall.id), - 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 add_meta_to_result(actions, response, "actions") def get_actions( self, - firewall, # type: Firewall - status=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - ): - # type: (...) -> 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` + firewall: Firewall | BoundFirewall, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(FirewallsClient, self).get_actions( - firewall, status=status, sort=sort + return self._iter_pages( + self.get_actions_list, + firewall, + status=status, + sort=sort, ) - def get_by_id(self, id): - # type: (int) -> BoundFirewall + def get_by_id(self, id: int) -> BoundFirewall: """Returns a specific Firewall object. :param id: int :return: :class:`BoundFirewall ` """ - response = self._client.request( - url="/firewalls/{firewall_id}".format(firewall_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFirewall(self, response["firewall"]) def get_list( self, - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - name=None, # type: Optional[str] - sort=None, # type: Optional[List[str]] - ): - # type: (...) -> PageResults[List[BoundFirewall]] + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + name: str | None = None, + sort: list[str] | None = None, + ) -> FirewallsPageResult: """Get a list of floating ips from this account :param label_selector: str (optional) @@ -235,7 +297,7 @@ def get_list( Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) :return: (List[:class:`BoundFirewall `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if label_selector is not None: params["label_selector"] = label_selector @@ -247,16 +309,20 @@ 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"] ] - return self._add_meta_to_result(firewalls, response) + return FirewallsPageResult(firewalls, Meta.parse_meta(response)) - def get_all(self, label_selector=None, name=None, sort=None): - # type: (Optional[str], Optional[str], Optional[List[str]]) -> List[BoundFirewall] + def get_all( + self, + label_selector: str | None = None, + name: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundFirewall]: """Get all floating ips from this account :param label_selector: str (optional) @@ -267,28 +333,29 @@ def get_all(self, label_selector=None, name=None, sort=None): Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) :return: List[:class:`BoundFirewall `] """ - return super(FirewallsClient, self).get_all( - label_selector=label_selector, name=name, sort=sort + return self._iter_pages( + self.get_list, + label_selector=label_selector, + name=name, + sort=sort, ) - def get_by_name(self, name): - # type: (str) -> BoundFirewall + def get_by_name(self, name: str) -> BoundFirewall | None: """Get Firewall by name :param name: str Used to get Firewall by name. :return: :class:`BoundFirewall ` """ - return super(FirewallsClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - name, # type: str - rules=None, # type: Optional[List[FirewallRule]] - labels=None, # type: Optional[str] - resources=None, # type: Optional[List[FirewallResource]] - ): - # type: (...) -> CreateFirewallResponse + name: str, + rules: list[FirewallRule] | None = None, + labels: str | None = None, + resources: list[FirewallResource] | None = None, + ) -> CreateFirewallResponse: """Creates a new Firewall. :param name: str @@ -300,7 +367,7 @@ def create( :return: :class:`CreateFirewallResponse ` """ - data = {"name": name} + data: dict[str, Any] = {"name": name} if labels is not None: data["labels"] = labels @@ -312,12 +379,13 @@ 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, _) for _ in response["actions"] + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] ] result = CreateFirewallResponse( @@ -325,8 +393,12 @@ def create( ) return result - def update(self, firewall, labels=None, name=None): - # type: (Firewall, Optional[Dict[str, str]], Optional[str]) -> BoundFirewall + def update( + self, + firewall: Firewall | BoundFirewall, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundFirewall: """Updates the description or labels of a Firewall. :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` @@ -336,89 +408,100 @@ def update(self, firewall, labels=None, name=None): New name to set :return: :class:`BoundFirewall ` """ - data = {} + data: dict[str, Any] = {} if labels is not None: data["labels"] = labels if name is not None: data["name"] = name response = self._client.request( - url="/firewalls/{firewall_id}".format(firewall_id=firewall.id), + url=f"{self._base_url}/{firewall.id}", method="PUT", json=data, ) return BoundFirewall(self, response["firewall"]) - def delete(self, firewall): - # type: (Firewall) -> bool + def delete(self, firewall: Firewall | BoundFirewall) -> bool: """Deletes a Firewall. :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` :return: boolean """ self._client.request( - url="/firewalls/{firewall_id}".format(firewall_id=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 return True - def set_rules(self, firewall, rules): - # type: (Firewall, List[FirewallRule]) -> List[BoundAction] + def set_rules( + self, + firewall: Firewall | BoundFirewall, + rules: list[FirewallRule], + ) -> list[BoundAction]: """Sets the rules of a Firewall. All existing rules will be overwritten. Pass an empty rules array to remove all rules. :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` :param rules: List[:class:`FirewallRule `] :return: List[:class:`BoundAction `] """ - data = {"rules": []} + data: dict[str, Any] = {"rules": []} for rule in rules: data["rules"].append(rule.to_payload()) response = self._client.request( - url="/firewalls/{firewall_id}/actions/set_rules".format( - firewall_id=firewall.id - ), + url=f"{self._base_url}/{firewall.id}/actions/set_rules", method="POST", json=data, ) - return [BoundAction(self._client.actions, _) for _ in response["actions"]] + return [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] - def apply_to_resources(self, firewall, resources): - # type: (Firewall, List[FirewallResource]) -> List[BoundAction] + def apply_to_resources( + self, + firewall: Firewall | BoundFirewall, + resources: list[FirewallResource], + ) -> list[BoundAction]: """Applies one Firewall to multiple resources. :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - data = {"apply_to": []} + data: dict[str, Any] = {"apply_to": []} for resource in resources: data["apply_to"].append(resource.to_payload()) response = self._client.request( - url="/firewalls/{firewall_id}/actions/apply_to_resources".format( - firewall_id=firewall.id - ), + url=f"{self._base_url}/{firewall.id}/actions/apply_to_resources", method="POST", json=data, ) - return [BoundAction(self._client.actions, _) for _ in response["actions"]] + return [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] - def remove_from_resources(self, firewall, resources): - # type: (Firewall, List[FirewallResource]) -> List[BoundAction] + def remove_from_resources( + self, + firewall: Firewall | BoundFirewall, + resources: list[FirewallResource], + ) -> list[BoundAction]: """Removes one Firewall from multiple resources. :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` :param resources: List[:class:`FirewallResource `] :return: List[:class:`BoundAction `] """ - data = {"remove_from": []} + data: dict[str, Any] = {"remove_from": []} for resource in resources: data["remove_from"].append(resource.to_payload()) response = self._client.request( - url="/firewalls/{firewall_id}/actions/remove_from_resources".format( - firewall_id=firewall.id - ), + url=f"{self._base_url}/{firewall.id}/actions/remove_from_resources", method="POST", json=data, ) - return [BoundAction(self._client.actions, _) for _ in response["actions"]] + return [ + 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 bcdcf07a..76a9b596 100644 --- a/hcloud/firewalls/domain.py +++ b/hcloud/firewalls/domain.py @@ -1,10 +1,26 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain +from typing import TYPE_CHECKING, Any +from ..core import BaseDomain, DomainIdentityMixin -class Firewall(BaseDomain): +if TYPE_CHECKING: + from ..actions import BoundAction + from ..servers import BoundServer, Server + from .client import BoundFirewall + + +__all__ = [ + "Firewall", + "FirewallRule", + "FirewallResource", + "FirewallResourceAppliedToResources", + "FirewallResourceLabelSelector", + "CreateFirewallResponse", +] + + +class Firewall(BaseDomain, DomainIdentityMixin): """Firewall Domain :param id: int @@ -21,20 +37,27 @@ class Firewall(BaseDomain): 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, id=None, name=None, labels=None, rules=None, applied_to=None, created=None + self, + id: int | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + rules: list[FirewallRule] | None = None, + applied_to: list[FirewallResource] | None = None, + created: str | None = None, ): self.id = id self.name = name 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: +class FirewallRule(BaseDomain): """Firewall Rule Domain :param direction: str @@ -52,7 +75,7 @@ class FirewallRule: Short description of the firewall rule """ - __slots__ = ( + __api_properties__ = ( "direction", "port", "protocol", @@ -60,6 +83,7 @@ class FirewallRule: "destination_ips", "description", ) + __slots__ = __api_properties__ DIRECTION_IN = "in" """Firewall Rule Direction In""" @@ -79,36 +103,40 @@ class FirewallRule: def __init__( self, - direction, # type: str - protocol, # type: str - source_ips, # type: List[str] - port=None, # type: Optional[str] - destination_ips=None, # type: Optional[List[str]] - description=None, # type: Optional[str] + direction: str, + protocol: str, + source_ips: list[str] | None = None, + port: str | None = None, + destination_ips: list[str] | None = None, + description: str | None = None, ): 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 - def to_payload(self): - payload = { + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + 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.update({"destination_ips": self.destination_ips}) + payload["destination_ips"] = self.destination_ips if self.port is not None: - payload.update({"port": self.port}) + payload["port"] = self.port if self.description is not None: - payload.update({"description": self.description}) + payload["description"] = self.description return payload -class FirewallResource: +class FirewallResource(BaseDomain): """Firewall Used By Domain :param type: str @@ -117,9 +145,12 @@ class FirewallResource: Server the Firewall is applied to :param label_selector: Optional[FirewallResourceLabelSelector] Label Selector for Servers the Firewall should be applied to + :param applied_to_resources: (read-only) List of effective resources the firewall is + applied to. """ - __slots__ = ("type", "server", "label_selector") + __api_properties__ = ("type", "server", "label_selector", "applied_to_resources") + __slots__ = __api_properties__ TYPE_SERVER = "server" """Firewall Used By Type Server""" @@ -128,35 +159,58 @@ class FirewallResource: def __init__( self, - type, # type: str - server=None, # type: Optional[Server] - label_selector=None, # type: Optional[FirewallResourceLabelSelector] + type: str, + server: Server | BoundServer | None = None, + label_selector: FirewallResourceLabelSelector | None = None, + applied_to_resources: list[FirewallResourceAppliedToResources] | None = None, ): self.type = type self.server = server self.label_selector = label_selector + self.applied_to_resources = applied_to_resources - def to_payload(self): - payload = { - "type": self.type, - } + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {"type": self.type} if self.server is not None: - payload.update({"server": {"id": self.server.id}}) + payload["server"] = {"id": self.server.id} if self.label_selector is not None: - payload.update( - {"label_selector": {"selector": self.label_selector.selector}} - ) + payload["label_selector"] = {"selector": self.label_selector.selector} return payload +class FirewallResourceAppliedToResources(BaseDomain): + """Firewall Resource applied to Domain + + :param type: Type of resource referenced + :param server: Server the Firewall is applied to + """ + + __api_properties__ = ("type", "server") + __slots__ = __api_properties__ + + def __init__( + self, + type: str, + server: BoundServer | None = None, + ): + self.type = type + self.server = server + + class FirewallResourceLabelSelector(BaseDomain): """FirewallResourceLabelSelector Domain :param selector: str Target label selector """ - def __init__(self, selector=None): + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + + def __init__(self, selector: str | None = None): self.selector = selector @@ -169,12 +223,13 @@ 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, - firewall, # type: BoundFirewall - actions, # type: BoundAction + firewall: BoundFirewall, + actions: list[BoundAction] | None, ): self.firewall = firewall self.actions = actions diff --git a/hcloud/floating_ips/__init__.py b/hcloud/floating_ips/__init__.py index 40a96afc..5ae08fca 100644 --- a/hcloud/floating_ips/__init__.py +++ b/hcloud/floating_ips/__init__.py @@ -1 +1,17 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundFloatingIP, + FloatingIPsClient, + FloatingIPsPageResult, +) +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 6283cb61..88e7ea42 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -1,63 +1,101 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result +from __future__ import annotations -from hcloud.floating_ips.domain import FloatingIP, CreateFloatingIPResponse -from hcloud.locations.client import BoundLocation +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 +from .domain import CreateFloatingIPResponse, FloatingIP + +if TYPE_CHECKING: + from .._client import Client + from ..locations import Location + from ..servers import BoundServer, Server + +__all__ = [ + "BoundFloatingIP", + "FloatingIPsPageResult", + "FloatingIPsClient", +] + + +class BoundFloatingIP(BoundModelBase[FloatingIP], FloatingIP): + _client: FloatingIPsClient -class BoundFloatingIP(BoundModelBase): model = FloatingIP - def __init__(self, client, data, complete=True): - from hcloud.servers.client import BoundServer + 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(BoundFloatingIP, self).__init__(client, data, complete) - - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResult[BoundAction, Meta] - """Returns all action objects 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 `) + 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: """ - return self._client.get_actions_list(self, status, sort, page, per_page) + Returns a paginated list of Actions for a Floating IP. - def get_actions(self, status=None, sort=None): - # type: (Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Floating 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, + ) - :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` + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Floating IP. - :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, description=None, labels=None, name=None): - # type: (Optional[str], Optional[Dict[str, str]], Optional[str]) -> BoundFloatingIP + def update( + self, + description: str | None = None, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundFloatingIP: """Updates the description or labels of a Floating IP. :param description: str (optional) @@ -68,46 +106,43 @@ def update(self, description=None, labels=None, name=None): 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): - # type: () -> bool + def delete(self) -> bool: """Deletes a Floating IP. If it is currently assigned to a server it will automatically get unassigned. :return: boolean """ return self._client.delete(self) - def change_protection(self, delete=None): - # type: (Optional[bool]) -> BoundAction + def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of the Floating IP. :param delete: boolean 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): - # type: (Server) -> BoundAction + def assign(self, server: Server | BoundServer) -> BoundAction: """Assigns a Floating IP to a server. - :param server: :class:`BoundServer ` or :class:`Server ` + :param server: :class:`BoundServer ` or :class:`Server ` 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): - # type: () -> BoundAction + 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. :return: :class:`BoundAction ` """ return self._client.unassign(self) - def change_dns_ptr(self, ip, dns_ptr): - # type: (str, str) -> BoundAction + def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to this Floating IP. :param ip: str @@ -116,97 +151,91 @@ def change_dns_ptr(self, ip, dns_ptr): 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 + +class FloatingIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/floating_ips" -class FloatingIPsClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "floating_ips" + actions: ResourceActionsClient + """Floating IPs scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, - floating_ip, # type: FloatingIP - status=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundAction], Meta] - """Returns all action objects for a Floating IP. + floating_ip: FloatingIP | BoundFloatingIP, + 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 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 = {} - 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="/floating_ips/{floating_ip_id}/actions".format( - floating_ip_id=floating_ip.id - ), - 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 add_meta_to_result(actions, response, "actions") def get_actions( self, - floating_ip, # type: FloatingIP - status=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - ): - # type: (...) -> 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` + floating_ip: FloatingIP | BoundFloatingIP, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(FloatingIPsClient, self).get_actions( - floating_ip, status=status, sort=sort + return self._iter_pages( + self.get_actions_list, + floating_ip, + status=status, + sort=sort, ) - def get_by_id(self, id): - # type: (int) -> BoundFloatingIP + def get_by_id(self, id: int) -> BoundFloatingIP: """Returns a specific Floating IP object. :param id: int :return: :class:`BoundFloatingIP ` """ - response = self._client.request( - url="/floating_ips/{floating_ip_id}".format(floating_ip_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundFloatingIP(self, response["floating_ip"]) def get_list( self, - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - name=None, # type: Optional[str] - ): - # type: (...) -> PageResults[List[BoundFloatingIP]] + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + name: str | None = None, + ) -> FloatingIPsPageResult: """Get a list of floating ips from this account :param label_selector: str (optional) @@ -219,7 +248,7 @@ def get_list( Can be used to filter networks by their name. :return: (List[:class:`BoundFloatingIP `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if label_selector is not None: params["label_selector"] = label_selector @@ -230,18 +259,19 @@ 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"] ] - return self._add_meta_to_result(floating_ips, response) + return FloatingIPsPageResult(floating_ips, Meta.parse_meta(response)) - def get_all(self, label_selector=None, name=None): - # type: (Optional[str], Optional[str]) -> List[BoundFloatingIP] + def get_all( + self, + label_selector: str | None = None, + name: str | None = None, + ) -> list[BoundFloatingIP]: """Get all floating ips from this account :param label_selector: str (optional) @@ -250,30 +280,26 @@ def get_all(self, label_selector=None, name=None): Can be used to filter networks by their name. :return: List[:class:`BoundFloatingIP `] """ - return super(FloatingIPsClient, self).get_all( - label_selector=label_selector, name=name - ) + return self._iter_pages(self.get_list, label_selector=label_selector, name=name) - def get_by_name(self, name): - # type: (str) -> BoundFloatingIP + def get_by_name(self, name: str) -> BoundFloatingIP | None: """Get Floating IP by name :param name: str Used to get Floating IP by name. :return: :class:`BoundFloatingIP ` """ - return super(FloatingIPsClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - type, # type: str - description=None, # type: Optional[str] - labels=None, # type: Optional[str] - home_location=None, # type: Optional[Location] - server=None, # type: Optional[Server] - name=None, # type: Optional[str] - ): - # type: (...) -> CreateFloatingIPResponse + type: str, + description: str | None = None, + labels: dict[str, str] | None = None, + home_location: Location | BoundLocation | None = None, + server: Server | BoundServer | None = None, + name: str | None = None, + ) -> CreateFloatingIPResponse: """Creates a new Floating IP assigned to a server. :param type: str @@ -289,7 +315,7 @@ def create( :return: :class:`CreateFloatingIPResponse ` """ - data = {"type": type} + data: dict[str, Any] = {"type": type} if description is not None: data["description"] = description if labels is not None: @@ -301,19 +327,24 @@ 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 ) return result - def update(self, floating_ip, description=None, labels=None, name=None): - # type: (FloatingIP, Optional[str], Optional[Dict[str, str]], Optional[str]) -> BoundFloatingIP + def update( + self, + floating_ip: FloatingIP | BoundFloatingIP, + description: str | None = None, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundFloatingIP: """Updates the description or labels of a Floating IP. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` @@ -325,7 +356,7 @@ def update(self, floating_ip, description=None, labels=None, name=None): New name to set :return: :class:`BoundFloatingIP ` """ - data = {} + data: dict[str, Any] = {} if description is not None: data["description"] = description if labels is not None: @@ -334,28 +365,30 @@ def update(self, floating_ip, description=None, labels=None, name=None): data["name"] = name response = self._client.request( - url="/floating_ips/{floating_ip_id}".format(floating_ip_id=floating_ip.id), + url=f"{self._base_url}/{floating_ip.id}", method="PUT", json=data, ) return BoundFloatingIP(self, response["floating_ip"]) - def delete(self, floating_ip): - # type: (FloatingIP) -> bool + def delete(self, floating_ip: FloatingIP | BoundFloatingIP) -> bool: """Deletes a Floating IP. If it is currently assigned to a server it will automatically get unassigned. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` :return: boolean """ self._client.request( - url="/floating_ips/{floating_ip_id}".format(floating_ip_id=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 return True - def change_protection(self, floating_ip, delete=None): - # type: (FloatingIP, Optional[bool]) -> BoundAction + def change_protection( + self, + floating_ip: FloatingIP | BoundFloatingIP, + delete: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of the Floating IP. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` @@ -363,21 +396,22 @@ def change_protection(self, floating_ip, delete=None): If true, prevents the Floating IP from being deleted :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) response = self._client.request( - url="/floating_ips/{floating_ip_id}/actions/change_protection".format( - floating_ip_id=floating_ip.id - ), + 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, floating_ip, server): - # type: (FloatingIP, Server) -> BoundAction + def assign( + self, + floating_ip: FloatingIP | BoundFloatingIP, + server: Server | BoundServer, + ) -> BoundAction: """Assigns a Floating IP to a server. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` @@ -386,31 +420,30 @@ def assign(self, floating_ip, server): :return: :class:`BoundAction ` """ response = self._client.request( - url="/floating_ips/{floating_ip_id}/actions/assign".format( - floating_ip_id=floating_ip.id - ), + 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): - # type: (FloatingIP) -> BoundAction + 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. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/floating_ips/{floating_ip_id}/actions/unassign".format( - floating_ip_id=floating_ip.id - ), + 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, floating_ip, ip, dns_ptr): - # type: (FloatingIP, str, str) -> BoundAction + def change_dns_ptr( + self, + floating_ip: FloatingIP | BoundFloatingIP, + ip: str, + dns_ptr: str, + ) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to this Floating IP. :param floating_ip: :class:`BoundFloatingIP ` or :class:`FloatingIP ` @@ -421,10 +454,8 @@ def change_dns_ptr(self, floating_ip, ip, dns_ptr): :return: :class:`BoundAction ` """ response = self._client.request( - url="/floating_ips/{floating_ip_id}/actions/change_dns_ptr".format( - floating_ip_id=floating_ip.id - ), + 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 bd5bf301..3946da7b 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -1,10 +1,25 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain +from typing import TYPE_CHECKING, TypedDict +from ..core import BaseDomain, DomainIdentityMixin -class FloatingIP(BaseDomain): +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 :param id: int @@ -33,7 +48,7 @@ class FloatingIP(BaseDomain): Name of the Floating IP """ - __slots__ = ( + __api_properties__ = ( "id", "type", "description", @@ -47,21 +62,22 @@ class FloatingIP(BaseDomain): "name", "created", ) + __slots__ = __api_properties__ def __init__( self, - id=None, - type=None, - description=None, - ip=None, - server=None, - dns_ptr=None, - home_location=None, - blocked=None, - protection=None, - labels=None, - created=None, - name=None, + id: int | None = None, + type: str | None = None, + description: str | None = None, + ip: str | None = None, + server: BoundServer | None = None, + dns_ptr: list[DNSPtr] | None = None, + home_location: BoundLocation | None = None, + blocked: bool | None = None, + protection: FloatingIPProtection | None = None, + labels: dict[str, str] | None = None, + created: str | None = None, + name: str | None = None, ): self.id = id self.type = type @@ -73,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 @@ -86,12 +106,13 @@ 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, - floating_ip, # type: BoundFloatingIP - action, # type: BoundAction + floating_ip: BoundFloatingIP, + action: BoundAction | None, ): self.floating_ip = floating_ip self.action = action diff --git a/hcloud/hcloud.py b/hcloud/hcloud.py deleted file mode 100644 index c8aea488..00000000 --- a/hcloud/hcloud.py +++ /dev/null @@ -1,241 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -import time -import requests - -from hcloud.actions.client import ActionsClient -from hcloud.certificates.client import CertificatesClient -from hcloud.floating_ips.client import FloatingIPsClient -from hcloud.networks.client import NetworksClient -from hcloud.isos.client import IsosClient -from hcloud.servers.client import ServersClient -from hcloud.server_types.client import ServerTypesClient -from hcloud.ssh_keys.client import SSHKeysClient -from hcloud.volumes.client import VolumesClient -from hcloud.images.client import ImagesClient -from hcloud.locations.client import LocationsClient -from hcloud.datacenters.client import DatacentersClient -from hcloud.load_balancers.client import LoadBalancersClient -from hcloud.load_balancer_types.client import LoadBalancerTypesClient -from hcloud.placement_groups.client import PlacementGroupsClient - -from .__version__ import VERSION -from .firewalls.client import FirewallsClient - - -class APIException(Exception): - """There was an error while performing an API Request""" - - def __init__(self, code, message, details): - self.code = code - self.message = message - self.details = details - - def __str__(self): - return self.message - - -class Client(object): - """Base Client for accessing the Hetzner Cloud API""" - - _version = VERSION - _retry_wait_time = 0.5 - __user_agent_prefix = "hcloud-python" - - def __init__( - self, - token, - api_endpoint="https://api.hetzner.cloud/v1", - application_name=None, - application_version=None, - poll_interval=1, - ): - """Create an new Client instance - - :param token: str - Hetzner Cloud API token - :param api_endpoint: str - Hetzner Cloud API endpoint (default is https://api.hetzner.cloud/v1) - :param application_name: str - Your application name (default is None) - :param application_version: str - Your application _version (default is None) - :param poll_interval: int - Interval for polling information from Hetzner Cloud API in seconds (default is 1) - """ - self.token = token - self._api_endpoint = api_endpoint - self._application_name = application_name - self._application_version = application_version - self._requests_session = requests.Session() - self.poll_interval = poll_interval - - self.datacenters = DatacentersClient(self) - """DatacentersClient Instance - - :type: :class:`DatacentersClient ` - """ - self.locations = LocationsClient(self) - """LocationsClient Instance - - :type: :class:`LocationsClient ` - """ - self.servers = ServersClient(self) - """ServersClient Instance - - :type: :class:`ServersClient ` - """ - self.server_types = ServerTypesClient(self) - """ServerTypesClient Instance - - :type: :class:`ServerTypesClient ` - """ - self.volumes = VolumesClient(self) - """VolumesClient Instance - - :type: :class:`VolumesClient ` - """ - self.actions = ActionsClient(self) - """ActionsClient Instance - - :type: :class:`ActionsClient ` - """ - self.images = ImagesClient(self) - """ImagesClient Instance - - :type: :class:`ImagesClient ` - """ - self.isos = IsosClient(self) - """ImagesClient Instance - - :type: :class:`IsosClient ` - """ - self.ssh_keys = SSHKeysClient(self) - """SSHKeysClient Instance - - :type: :class:`SSHKeysClient ` - """ - self.floating_ips = FloatingIPsClient(self) - """FloatingIPsClient Instance - - :type: :class:`FloatingIPsClient ` - """ - self.networks = NetworksClient(self) - """NetworksClient Instance - - :type: :class:`NetworksClient ` - """ - self.certificates = CertificatesClient(self) - """CertificatesClient Instance - - :type: :class:`CertificatesClient ` - """ - - self.load_balancers = LoadBalancersClient(self) - """LoadBalancersClient Instance - - :type: :class:`LoadBalancersClient ` - """ - - self.load_balancer_types = LoadBalancerTypesClient(self) - """LoadBalancerTypesClient Instance - - :type: :class:`LoadBalancerTypesClient ` - """ - - self.firewalls = FirewallsClient(self) - """FirewallsClient Instance - - :type: :class:`FirewallsClient ` - """ - - self.placement_groups = PlacementGroupsClient(self) - """PlacementGroupsClient Instance - - :type: :class:`PlacementGroupsClient ` - """ - - def _get_user_agent(self): - """Get the user agent of the hcloud-python instance with the user application name (if specified) - - :return: str - The user agent of this hcloud-python instance - """ - if self._application_name is not None and self._application_version is None: - return "{application_name} {prefix}/{version}".format( - application_name=self._application_name, - prefix=self.__user_agent_prefix, - version=self._version, - ) - elif ( - self._application_name is not None and self._application_version is not None - ): - return "{application_name}/{application_version} {prefix}/{version}".format( - application_name=self._application_name, - application_version=self._application_version, - prefix=self.__user_agent_prefix, - version=self._version, - ) - else: - return "{prefix}/{version}".format( - prefix=self.__user_agent_prefix, version=self._version - ) - - def _get_headers(self): - - headers = { - "User-Agent": self._get_user_agent(), - "Authorization": "Bearer {token}".format(token=self.token), - } - return headers - - def _raise_exception_from_response(self, response): - raise APIException( - code=response.status_code, - message=response.reason, - details={"content": response.content}, - ) - - def _raise_exception_from_json_content(self, json_content): - raise APIException( - code=json_content["error"]["code"], - message=json_content["error"]["message"], - details=json_content["error"]["details"], - ) - - def request(self, method, url, tries=1, **kwargs): - """Perform a request to the Hetzner Cloud API, wrapper around requests.request - - :param method: str - HTTP Method to perform the Request - :param url: str - URL of the Endpoint - :param tries: int - Tries of the request (used internally, should not be set by the user) - :return: Response - :rtype: requests.Response - """ - response = self._requests_session.request( - method, self._api_endpoint + url, headers=self._get_headers(), **kwargs - ) - - json_content = response.content - try: - if len(json_content) > 0: - json_content = response.json() - except (TypeError, ValueError): - self._raise_exception_from_response(response) - - if not response.ok: - if json_content: - if json_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) - else: - self._raise_exception_from_json_content(json_content) - else: - self._raise_exception_from_response(response) - - return json_content diff --git a/hcloud/helpers/__init__.py b/hcloud/helpers/__init__.py index 40a96afc..e8ec1dcb 100644 --- a/hcloud/helpers/__init__.py +++ b/hcloud/helpers/__init__.py @@ -1 +1,7 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .labels import LabelValidator + +__all__ = [ + "LabelValidator", +] diff --git a/hcloud/helpers/labels.py b/hcloud/helpers/labels.py new file mode 100644 index 00000000..78f43142 --- /dev/null +++ b/hcloud/helpers/labels.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import re + +__all__ = [ + "LabelValidator", +] + + +class LabelValidator: + KEY_REGEX = re.compile( + r"^([a-z0-9A-Z]((?:[\-_.]|[a-z0-9A-Z]){0,253}[a-z0-9A-Z])?/)?[a-z0-9A-Z]((?:[\-_.]|[a-z0-9A-Z]|){0,61}[a-z0-9A-Z])?$" + ) + VALUE_REGEX = re.compile( + r"^(([a-z0-9A-Z](?:[\-_.]|[a-z0-9A-Z]){0,61})?[a-z0-9A-Z]$|$)" + ) + + @staticmethod + def validate(labels: dict[str, str]) -> bool: + """Validates Labels. If you want to know which key/value pair of the dict is not correctly formatted + use :func:`~hcloud.helpers.labels.validate_verbose`. + + :return: bool + """ + for key, value in labels.items(): + if LabelValidator.KEY_REGEX.match(key) is None: + return False + if LabelValidator.VALUE_REGEX.match(value) is None: + return False + return True + + @staticmethod + def validate_verbose(labels: dict[str, str]) -> tuple[bool, str]: + """Validates Labels and returns the corresponding error message if something is wrong. Returns True, + if everything is fine. + + :return: bool, str + """ + for key, value in labels.items(): + if LabelValidator.KEY_REGEX.match(key) is None: + return ( + False, + f"label key {key} is not correctly formatted", + ) + if LabelValidator.VALUE_REGEX.match(value) is None: + return ( + False, + f"label value {value} (key: {key}) is not correctly formatted", + ) + return True, "" diff --git a/hcloud/images/__init__.py b/hcloud/images/__init__.py index 40a96afc..c299722c 100644 --- a/hcloud/images/__init__.py +++ b/hcloud/images/__init__.py @@ -1 +1,13 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 4b37a5f7..2566a96f 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -1,62 +1,102 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result +from __future__ import annotations -from hcloud.images.domain import Image +import warnings +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 Image + +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundImage", + "ImagesPageResult", + "ImagesClient", +] + + +class BoundImage(BoundModelBase[Image], Image): + _client: ImagesClient -class BoundImage(BoundModelBase): model = Image - def __init__(self, client, data): - from hcloud.servers.client import BoundServer + 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(BoundImage, self).__init__(client, data) + super().__init__(client, data) - def get_actions_list(self, sort=None, page=None, per_page=None, status=None): - # type: (Optional[List[str]], Optional[int], Optional[int], Optional[List[str]]) -> PageResult[BoundAction, Meta] - """Returns a list of action objects for the image. + 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 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=None, status=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for the image. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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, description=None, type=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundImage + def update( + self, + description: str | None = None, + type: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundImage: """Updates the Image. You may change the description, convert a Backup image to a Snapshot Image or change the image labels. :param description: str (optional) @@ -68,115 +108,115 @@ def update(self, description=None, type=None, labels=None): 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): - # type: () -> bool + def delete(self) -> bool: """Deletes an Image. Only images of type snapshot and backup can be deleted. :return: bool """ return self._client.delete(self) - def change_protection(self, delete=None): - # type: (Optional[bool]) -> BoundAction + def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of the image. Can only be used on snapshots. :param delete: bool 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 -class ImagesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "images" +class ImagesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/images" + + actions: ResourceActionsClient + """Images scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) def get_actions_list( self, - image, # type: Image - sort=None, # type: Optional[List[str]] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - status=None, # type: Optional[List[str]] - ): - # type: (...) -> PageResults[List[BoundAction], Meta] - """Returns a list of action objects for an image. + image: Image | BoundImage, + 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 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 = {} - 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="/images/{image_id}/actions".format(image_id=image.id), - 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 add_meta_to_result(actions, response, "actions") def get_actions( self, - image, # type: Image - sort=None, # type: Optional[List[str]] - status=None, # type: Optional[List[str]] - ): - # type: (...) -> List[BoundAction] - """Returns all action objects for an image. + image: Image | BoundImage, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(ImagesClient, self).get_actions(image, sort=sort, status=status) + return self._iter_pages( + self.get_actions_list, + image, + status=status, + sort=sort, + ) - def get_by_id(self, id): - # type: (int) -> BoundImage + def get_by_id(self, id: int) -> BoundImage: """Get a specific Image :param id: int - :return: :class:`BoundImage PageResults[List[BoundImage]] + name: str | None = None, + label_selector: str | None = None, + bound_to: list[str] | None = None, + type: list[str] | None = None, + architecture: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + status: list[str] | None = None, + include_deprecated: bool | None = None, + ) -> ImagesPageResult: """Get all images :param name: str (optional) @@ -187,6 +227,8 @@ def get_list( Server Id linked to the image. Only available for images of type backup :param type: List[str] (optional) Choices: system snapshot backup + :param architecture: List[str] (optional) + Choices: x86 arm :param status: List[str] (optional) Can be used to filter images by their status. The response will only contain images matching the status. :param sort: List[str] (optional) @@ -199,7 +241,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundImage `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if label_selector is not None: @@ -208,6 +250,8 @@ def get_list( params["bound_to"] = bound_to if type is not None: params["type"] = type + if architecture is not None: + params["architecture"] = architecture if sort is not None: params["sort"] = sort if page is not None: @@ -218,22 +262,22 @@ def get_list( params["status"] = per_page if include_deprecated is not None: params["include_deprecated"] = include_deprecated - response = self._client.request(url="/images", method="GET", params=params) + response = self._client.request(url=self._base_url, method="GET", params=params) images = [BoundImage(self, image_data) for image_data in response["images"]] - return self._add_meta_to_result(images, response) + return ImagesPageResult(images, Meta.parse_meta(response)) def get_all( self, - name=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - bound_to=None, # type: Optional[List[str]] - type=None, # type: Optional[List[str]] - sort=None, # type: Optional[List[str]] - status=None, # type: Optional[List[str]] - include_deprecated=None, # type: Optional[bool] - ): - # type: (...) -> List[BoundImage] + name: str | None = None, + label_selector: str | None = None, + bound_to: list[str] | None = None, + type: list[str] | None = None, + architecture: list[str] | None = None, + sort: list[str] | None = None, + status: list[str] | None = None, + include_deprecated: bool | None = None, + ) -> list[BoundImage]: """Get all images :param name: str (optional) @@ -244,6 +288,8 @@ def get_all( Server Id linked to the image. Only available for images of type backup :param type: List[str] (optional) Choices: system snapshot backup + :param architecture: List[str] (optional) + Choices: x86 arm :param status: List[str] (optional) Can be used to filter images by their status. The response will only contain images matching the status. :param sort: List[str] (optional) @@ -252,28 +298,67 @@ def get_all( Include deprecated images in the response. Default: False :return: List[:class:`BoundImage `] """ - return super(ImagesClient, self).get_all( + return self._iter_pages( + self.get_list, name=name, label_selector=label_selector, bound_to=bound_to, type=type, + architecture=architecture, sort=sort, status=status, include_deprecated=include_deprecated, ) - def get_by_name(self, name): - # type: (str) -> BoundImage + def get_by_name(self, name: str) -> BoundImage | None: """Get image by name :param name: str Used to get image by name. :return: :class:`BoundImage ` + + .. deprecated:: 1.19 + Use :func:`hcloud.images.client.ImagesClient.get_by_name_and_architecture` instead. + """ + warnings.warn( + "The 'hcloud.images.client.ImagesClient.get_by_name' method is deprecated, please use the " + "'hcloud.images.client.ImagesClient.get_by_name_and_architecture' method instead.", + DeprecationWarning, + stacklevel=2, + ) + return self._get_first_by(self.get_list, name=name) + + def get_by_name_and_architecture( + self, + name: str, + architecture: str, + *, + include_deprecated: bool | None = None, + ) -> BoundImage | None: + """Get image by name + + :param name: str + Used to identify the image. + :param architecture: str + Used to identify the image. + :param include_deprecated: bool (optional) + Include deprecated images. Default: False + :return: :class:`BoundImage ` """ - return super(ImagesClient, self).get_by_name(name) + return self._get_first_by( + self.get_list, + name=name, + architecture=[architecture], + include_deprecated=include_deprecated, + ) - def update(self, image, description=None, type=None, labels=None): - # type:(Image, Optional[str], Optional[str], Optional[Dict[str, str]]) -> BoundImage + def update( + self, + image: Image | BoundImage, + description: str | None = None, + type: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundImage: """Updates the Image. You may change the description, convert a Backup image to a Snapshot Image or change the image labels. :param image: :class:`BoundImage ` or :class:`Image ` @@ -286,7 +371,7 @@ def update(self, image, description=None, type=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundImage ` """ - data = {} + data: dict[str, Any] = {} if description is not None: data.update({"description": description}) if type is not None: @@ -294,25 +379,25 @@ def update(self, image, description=None, type=None, labels=None): if labels is not None: data.update({"labels": labels}) response = self._client.request( - url="/images/{image_id}".format(image_id=image.id), method="PUT", json=data + url=f"{self._base_url}/{image.id}", method="PUT", json=data ) return BoundImage(self, response["image"]) - def delete(self, image): - # type: (Image) -> bool + def delete(self, image: Image | BoundImage) -> bool: """Deletes an Image. Only images of type snapshot and backup can be deleted. :param :class:`BoundImage ` or :class:`Image ` :return: bool """ - self._client.request( - url="/images/{image_id}".format(image_id=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 - def change_protection(self, image, delete=None): - # type: (Image, Optional[bool], Optional[bool]) -> BoundAction + def change_protection( + self, + image: Image | BoundImage, + delete: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of the image. Can only be used on snapshots. :param image: :class:`BoundImage ` or :class:`Image ` @@ -320,15 +405,13 @@ def change_protection(self, image, delete=None): If true, prevents the snapshot from being deleted :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) response = self._client.request( - url="/images/{image_id}/actions/change_protection".format( - image_id=image.id - ), + 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 cd65673c..915ace64 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,7 +1,20 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from typing import TYPE_CHECKING, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..servers import BoundServer, Server + from .client import BoundImage + + +__all__ = [ + "Image", + "ImageProtection", + "CreateImageResponse", +] class Image(BaseDomain, DomainIdentityMixin): @@ -31,6 +44,8 @@ class Image(BaseDomain, DomainIdentityMixin): Flavor of operating system contained in the image Choices: `ubuntu`, `centos`, `debian`, `fedora`, `unknown` :param os_version: str, None Operating system version + :param architecture: str + CPU Architecture that the image is compatible with. Choices: `x86`, `arm` :param rapid_deploy: bool Indicates that rapid deploy of the image is available :param protection: dict @@ -41,7 +56,7 @@ class Image(BaseDomain, DomainIdentityMixin): User-defined labels (key-value pairs) """ - __slots__ = ( + __api_properties__ = ( "id", "name", "type", @@ -51,6 +66,7 @@ class Image(BaseDomain, DomainIdentityMixin): "bound_to", "os_flavor", "os_version", + "architecture", "rapid_deploy", "created_from", "status", @@ -59,37 +75,41 @@ class Image(BaseDomain, DomainIdentityMixin): "created", "deprecated", ) + __slots__ = __api_properties__ + # pylint: disable=too-many-locals def __init__( self, - id=None, - name=None, - type=None, - created=None, - description=None, - image_size=None, - disk_size=None, - deprecated=None, - bound_to=None, - os_flavor=None, - os_version=None, - rapid_deploy=None, - created_from=None, - protection=None, - labels=None, - status=None, + id: int | None = None, + name: str | None = None, + type: str | None = None, + created: str | None = None, + description: str | None = None, + image_size: int | None = None, + disk_size: int | None = None, + deprecated: str | None = None, + bound_to: Server | BoundServer | None = None, + os_flavor: str | None = None, + os_version: str | None = None, + architecture: str | None = None, + rapid_deploy: bool | None = None, + created_from: Server | BoundServer | 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 + self.architecture = architecture self.rapid_deploy = rapid_deploy self.created_from = created_from self.protection = protection @@ -97,6 +117,10 @@ def __init__( self.status = status +class ImageProtection(TypedDict): + delete: bool + + class CreateImageResponse(BaseDomain): """Create Image Response Domain @@ -106,12 +130,13 @@ 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, - action, # type: BoundAction - image, # type: BoundImage + action: BoundAction, + image: BoundImage, ): self.action = action self.image = image diff --git a/hcloud/isos/__init__.py b/hcloud/isos/__init__.py index 40a96afc..91f137c3 100644 --- a/hcloud/isos/__init__.py +++ b/hcloud/isos/__init__.py @@ -1 +1,11 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 b7637435..b9c3a5a0 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -1,73 +1,108 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.isos.domain import Iso +from typing import Any, NamedTuple +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import Iso + +__all__ = [ + "BoundIso", + "IsosPageResult", + "IsosClient", +] + + +class BoundIso(BoundModelBase[Iso], Iso): + _client: IsosClient -class BoundIso(BoundModelBase): model = Iso -class IsosClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "isos" +class IsosPageResult(NamedTuple): + isos: list[BoundIso] + meta: Meta + - def get_by_id(self, id): - # type: (int) -> BoundIso +class IsosClient(ResourceClientBase): + _base_url = "/isos" + + def get_by_id(self, id: int) -> BoundIso: """Get a specific ISO by its id :param id: int :return: :class:`BoundIso ` """ - response = self._client.request( - url="/isos/{iso_id}".format(iso_id=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=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundIso], Meta] + name: str | None = None, + architecture: list[str] | None = None, + include_architecture_wildcard: bool | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> IsosPageResult: """Get a list of ISOs :param name: str (optional) 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_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. :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:`BoundIso `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name + if architecture is not None: + params["architecture"] = architecture + if include_architecture_wildcard is not None: + params["include_architecture_wildcard"] = include_architecture_wildcard if page is not None: params["page"] = page 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 self._add_meta_to_result(isos, response) + return IsosPageResult(isos, Meta.parse_meta(response)) - def get_all(self, name=None): - # type: (Optional[str]) -> List[BoundIso] + def get_all( + self, + name: str | None = None, + architecture: list[str] | None = None, + include_architecture_wildcard: bool | None = None, + ) -> list[BoundIso]: """Get all ISOs :param name: str (optional) 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_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 `] """ - return super(IsosClient, self).get_all(name=name) + return self._iter_pages( + self.get_list, + name=name, + architecture=architecture, + include_architecture_wildcard=include_architecture_wildcard, + ) - def get_by_name(self, name): - # type: (str) -> BoundIso + def get_by_name(self, name: str) -> BoundIso | None: """Get iso by name :param name: str Used to get iso by name. :return: :class:`BoundIso ` """ - return super(IsosClient, self).get_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 aea06b32..5fd50c6e 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -1,7 +1,15 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +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): @@ -15,22 +23,53 @@ class Iso(BaseDomain, DomainIdentityMixin): Description of the ISO :param type: str Type of the ISO. Choices: `public`, `private` + :param architecture: str, None + CPU Architecture that the ISO is compatible with. None means that the compatibility is unknown. Choices: `x86`, `arm` :param deprecated: datetime, None - ISO 8601 timestamp of deprecation, None if ISO is still available. After the deprecation time it will no longer be possible to attach the ISO to servers. + ISO 8601 timestamp of deprecation, None if ISO is still available. After the deprecation time it will no longer be possible to attach the ISO to servers. This field is deprecated. Use `deprecation` instead. + :param deprecation: :class:`DeprecationInfo `, None + Describes if, when & how the resources was deprecated. If this field is set to None the resource is not + deprecated. If it has a value, it is considered deprecated. """ - __slots__ = ("id", "name", "type", "description", "deprecated") + __api_properties__ = ( + "id", + "name", + "type", + "architecture", + "description", + "deprecation", + ) + __slots__ = __api_properties__ def __init__( self, - id=None, - name=None, - type=None, - description=None, - deprecated=None, + id: int | None = None, + name: str | None = None, + type: str | None = None, + architecture: str | None = None, + description: str | None = None, + deprecated: str | None = None, # pylint: disable=unused-argument + deprecation: dict[str, Any] | None = None, ): self.id = id self.name = name self.type = type + self.architecture = architecture self.description = description - self.deprecated = isoparse(deprecated) if deprecated else None + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) + + @property + def deprecated(self) -> datetime | None: + """ + ISO 8601 timestamp of deprecation, None if ISO is still available. + """ + warn( + "The `deprecated` field is deprecated, please use the `deprecation` field instead.", + DeprecationWarning, + ) + if self.deprecation is None: + return None + 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 40a96afc..1b147bf7 100644 --- a/hcloud/load_balancer_types/__init__.py +++ b/hcloud/load_balancer_types/__init__.py @@ -1 +1,15 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundLoadBalancerType, + LoadBalancerTypesClient, + LoadBalancerTypesPageResult, +) +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 341650a4..87d44e2f 100644 --- a/hcloud/load_balancer_types/client.py +++ b/hcloud/load_balancer_types/client.py @@ -1,31 +1,49 @@ -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin -from hcloud.load_balancer_types.domain import LoadBalancerType +from __future__ import annotations +from typing import Any, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import LoadBalancerType + +__all__ = [ + "BoundLoadBalancerType", + "LoadBalancerTypesPageResult", + "LoadBalancerTypesClient", +] + + +class BoundLoadBalancerType(BoundModelBase[LoadBalancerType], LoadBalancerType): + _client: LoadBalancerTypesClient -class BoundLoadBalancerType(BoundModelBase): model = LoadBalancerType -class LoadBalancerTypesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "load_balancer_types" +class LoadBalancerTypesPageResult(NamedTuple): + load_balancer_types: list[BoundLoadBalancerType] + meta: Meta + - def get_by_id(self, id): - # type: (int) -> load_balancer_types.client.BoundLoadBalancerType +class LoadBalancerTypesClient(ResourceClientBase): + _base_url = "/load_balancer_types" + + def get_by_id(self, id: int) -> BoundLoadBalancerType: """Returns a specific Load Balancer Type. :param id: int :return: :class:`BoundLoadBalancerType ` """ response = self._client.request( - url="/load_balancer_types/{load_balancer_type_id}".format( - load_balancer_type_id=id - ), + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancerType(self, response["load_balancer_type"]) - def get_list(self, name=None, page=None, per_page=None): - # type: (Optional[str], Optional[int], Optional[int]) -> PageResults[List[BoundLoadBalancerType], Meta] + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> LoadBalancerTypesPageResult: """Get a list of Load Balancer types :param name: str (optional) @@ -36,7 +54,7 @@ def get_list(self, name=None, page=None, per_page=None): Specifies how many results are returned by page :return: (List[:class:`BoundLoadBalancerType `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if page is not None: @@ -44,31 +62,29 @@ def get_list(self, name=None, page=None, per_page=None): 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"] ] - return self._add_meta_to_result(load_balancer_types, response) + return LoadBalancerTypesPageResult( + load_balancer_types, Meta.parse_meta(response) + ) - def get_all(self, name=None): - # type: (Optional[str]) -> List[BoundLoadBalancerType] + def get_all(self, name: str | None = None) -> list[BoundLoadBalancerType]: """Get all Load Balancer types :param name: str (optional) Can be used to filter Load Balancer type by their name. :return: List[:class:`BoundLoadBalancerType `] """ - return super(LoadBalancerTypesClient, self).get_all(name=name) + return self._iter_pages(self.get_list, name=name) - def get_by_name(self, name): - # type: (str) -> BoundLoadBalancerType + def get_by_name(self, name: str) -> BoundLoadBalancerType | None: """Get Load Balancer type by name :param name: str Used to get Load Balancer type by name. :return: :class:`BoundLoadBalancerType ` """ - return super(LoadBalancerTypesClient, self).get_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 3356679e..1e594e94 100644 --- a/hcloud/load_balancer_types/domain.py +++ b/hcloud/load_balancer_types/domain.py @@ -1,56 +1,64 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin - - -class LoadBalancerType(BaseDomain, DomainIdentityMixin): - """LoadBalancerType Domain - - :param id: int - ID of the Load Balancer type - :param name: str - Name of the Load Balancer type - :param description: str - Description of the Load Balancer type - :param max_connections: int - Max amount of connections the Load Balancer can handle - :param max_services: int - Max amount of services the Load Balancer can handle - :param max_targets: int - 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 - Prices in different locations - - """ - - __slots__ = ( - "id", - "name", - "description", - "max_connections", - "max_services", - "max_targets", - "max_assigned_certificates", - "prices", - ) - - def __init__( - self, - id=None, - name=None, - description=None, - max_connections=None, - max_services=None, - max_targets=None, - max_assigned_certificates=None, - prices=None, - ): - self.id = id - self.name = name - self.description = description - self.max_connections = max_connections - self.max_services = max_services - self.max_targets = max_targets - self.max_assigned_certificates = max_assigned_certificates - self.prices = prices +from __future__ import annotations + +from typing import Any + +from ..core import BaseDomain, DomainIdentityMixin + +__all__ = [ + "LoadBalancerType", +] + + +class LoadBalancerType(BaseDomain, DomainIdentityMixin): + """LoadBalancerType Domain + + :param id: int + ID of the Load Balancer type + :param name: str + Name of the Load Balancer type + :param description: str + Description of the Load Balancer type + :param max_connections: int + Max amount of connections the Load Balancer can handle + :param max_services: int + Max amount of services the Load Balancer can handle + :param max_targets: int + 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: List of dict + Prices in different locations + + """ + + __api_properties__ = ( + "id", + "name", + "description", + "max_connections", + "max_services", + "max_targets", + "max_assigned_certificates", + "prices", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + description: str | None = None, + max_connections: int | None = None, + max_services: int | None = None, + max_targets: int | None = None, + max_assigned_certificates: int | None = None, + prices: list[dict[str, Any]] | None = None, + ): + self.id = id + self.name = name + self.description = description + self.max_connections = max_connections + self.max_services = max_services + self.max_targets = max_targets + self.max_assigned_certificates = max_assigned_certificates + self.prices = prices diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index e69de29b..9b07d026 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from .client import ( + BoundLoadBalancer, + LoadBalancersClient, + LoadBalancersPageResult, +) +from .domain import ( + CreateLoadBalancerResponse, + GetMetricsResponse, + IPv4Address, + IPv6Network, + LoadBalancer, + 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 d23acf94..1986905b 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -1,37 +1,71 @@ -# -*- coding: utf-8 -*- -from hcloud.certificates.client import BoundCertificate -from hcloud.servers.client import BoundServer +from __future__ import annotations -from hcloud.load_balancer_types.client import BoundLoadBalancerType -from hcloud.locations.client import BoundLocation -from hcloud.networks.client import BoundNetwork +from datetime import datetime +from typing import TYPE_CHECKING, Any, NamedTuple -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result +from dateutil.parser import isoparse -from hcloud.actions.client import BoundAction -from hcloud.load_balancers.domain import ( - LoadBalancer, +from ..actions import ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..certificates import BoundCertificate +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..load_balancer_types import BoundLoadBalancerType +from ..locations import BoundLocation +from ..metrics import Metrics +from ..networks import BoundNetwork +from ..servers import BoundServer +from .domain import ( + CreateLoadBalancerResponse, + GetMetricsResponse, IPv4Address, IPv6Network, - PublicNetwork, - PrivateNet, - CreateLoadBalancerResponse, - LoadBalancerTarget, + LoadBalancer, + LoadBalancerAlgorithm, + LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancerService, LoadBalancerServiceHttp, - LoadBalancerHealthCheck, - LoadBalancerHealtCheckHttp, - LoadBalancerAlgorithm, - LoadBalancerTargetLabelSelector, + LoadBalancerTarget, + LoadBalancerTargetHealthStatus, LoadBalancerTargetIP, + LoadBalancerTargetLabelSelector, + MetricsType, + PrivateNet, + PublicNetwork, ) +if TYPE_CHECKING: + from .._client import Client + from ..load_balancer_types import LoadBalancerType + from ..locations import Location + from ..networks import Network + + +__all__ = [ + "BoundLoadBalancer", + "LoadBalancersPageResult", + "LoadBalancersClient", +] + + +class BoundLoadBalancer(BoundModelBase[LoadBalancer], LoadBalancer): + _client: LoadBalancersClient -class BoundLoadBalancer(BoundModelBase): model = LoadBalancer - def __init__(self, client, data, complete=True): + # pylint: disable=too-many-branches,too-many-locals + def __init__( + self, + client: LoadBalancersClient, + data: dict[str, Any], + complete: bool = True, + ): algorithm = data.get("algorithm") if algorithm: data["algorithm"] = LoadBalancerAlgorithm(type=algorithm["type"]) @@ -49,7 +83,7 @@ def __init__(self, client, data, complete=True): private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -66,7 +100,7 @@ def __init__(self, client, data, complete=True): tmp_target = LoadBalancerTarget(type=target["type"]) if target["type"] == "server": tmp_target.server = BoundServer( - client._client.servers, data=target["server"], complete=False + client._parent.servers, data=target["server"], complete=False ) tmp_target.use_private_ip = target["use_private_ip"] elif target["type"] == "label_selector": @@ -76,6 +110,17 @@ def __init__(self, client, data, complete=True): 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 + ] + tmp_targets.append(tmp_target) data["targets"] = tmp_targets @@ -98,7 +143,7 @@ def __init__(self, client, data, complete=True): ) tmp_service.http.certificates = [ BoundCertificate( - client._client.certificates, + client._parent.certificates, {"id": certificate}, complete=False, ) @@ -113,7 +158,7 @@ def __init__(self, client, data, complete=True): 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"], @@ -126,17 +171,20 @@ def __init__(self, client, data, complete=True): 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(BoundLoadBalancer, self).__init__(client, data, complete) + super().__init__(client, data, complete) - def update(self, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundLoadBalancer + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundLoadBalancer: """Updates a Load Balancer. You can update a Load Balancers name and a Load Balancers labels. :param name: str (optional) @@ -145,46 +193,74 @@ def update(self, name=None, labels=None): 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): - # type: () -> BoundAction + def delete(self) -> bool: """Deletes a Load Balancer. :return: boolean """ return self._client.delete(self) - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a Load Balancer. + def get_metrics( + self, + type: MetricsType, + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a LoadBalancer. + + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + return self._client.get_metrics( + self, + type=type, + start=start, + end=end, + step=step, + ) - :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 `) - """ - return self._client.get_actions_list(self, status, sort, page, per_page) + 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 Load Balancer. + + :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=None, sort=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Load Balancer. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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): - # type: (LoadBalancerService) -> List[BoundAction] + def add_service(self, service: LoadBalancerService) -> BoundAction: """Adds a service to a Load Balancer. :param service: :class:`LoadBalancerService ` @@ -193,8 +269,7 @@ def add_service(self, service): """ return self._client.add_service(self, service=service) - def update_service(self, service): - # type: (LoadBalancerService) -> List[BoundAction] + def update_service(self, service: LoadBalancerService) -> BoundAction: """Updates a service of an Load Balancer. :param service: :class:`LoadBalancerService ` @@ -203,48 +278,43 @@ def update_service(self, service): """ return self._client.update_service(self, service=service) - def delete_service(self, service): - # type: (LoadBalancerService) -> List[BoundAction] + def delete_service(self, service: LoadBalancerService) -> BoundAction: """Deletes a service from a Load Balancer. :param service: :class:`LoadBalancerService ` 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): - # type: (LoadBalancerTarget) -> List[BoundAction] + def add_target(self, target: LoadBalancerTarget) -> BoundAction: """Adds a target to a Load Balancer. :param target: :class:`LoadBalancerTarget ` 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): - # type: (LoadBalancerTarget) -> List[BoundAction] + def remove_target(self, target: LoadBalancerTarget) -> BoundAction: """Removes a target from a Load Balancer. :param target: :class:`LoadBalancerTarget ` 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): - # type: (LoadBalancerAlgorithm) -> List[BoundAction] + def change_algorithm(self, algorithm: LoadBalancerAlgorithm) -> BoundAction: """Changes the algorithm used by the Load Balancer :param algorithm: :class:`LoadBalancerAlgorithm ` 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, dns_ptr): - # type: (str, str) -> BoundAction + 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. :param ip: str @@ -253,89 +323,114 @@ def change_dns_ptr(self, ip, dns_ptr): 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): - # type: (LoadBalancerService) -> List[BoundAction] + def change_protection(self, delete: bool) -> BoundAction: """Changes the protection configuration of a Load Balancer. :param delete: boolean 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, ip=None): - # type: (Union[Network,BoundNetwork],Optional[str]) -> BoundAction + 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): - # type: ( Union[Network,BoundNetwork]) -> BoundAction + def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a Load Balancer from a Network. :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): - # type: () -> BoundAction + def enable_public_interface(self) -> BoundAction: """Enables the public interface of a Load Balancer. :return: :class:`BoundAction ` """ return self._client.enable_public_interface(self) - def disable_public_interface(self): - # type: () -> BoundAction + def disable_public_interface(self) -> BoundAction: """Disables the public interface of a Load Balancer. :return: :class:`BoundAction ` """ return self._client.disable_public_interface(self) - def change_type(self, load_balancer_type): - # type: (Union[LoadBalancerType,BoundLoadBalancerType]) -> BoundAction + def change_type( + self, + load_balancer_type: LoadBalancerType | BoundLoadBalancerType, + ) -> BoundAction: """Changes the type of a Load Balancer. :param load_balancer_type: :class:`BoundLoadBalancerType ` or :class:`LoadBalancerType ` 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 -class LoadBalancersClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "load_balancers" +class LoadBalancersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/load_balancers" - def get_by_id(self, id): - # type: (int) -> BoundLoadBalancer + actions: ResourceActionsClient + """Load Balancers scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_by_id(self, id: int) -> BoundLoadBalancer: """Get a specific Load Balancer :param id: int :return: :class:`BoundLoadBalancer ` """ response = self._client.request( - url="/load_balancers/{load_balancer_id}".format(load_balancer_id=id), + url=f"{self._base_url}/{id}", method="GET", ) return BoundLoadBalancer(self, response["load_balancer"]) def get_list( self, - name=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundLoadBalancer], Meta] + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> LoadBalancersPageResult: """Get a list of Load Balancers from this account :param name: str (optional) @@ -348,7 +443,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundLoadBalancer `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if label_selector is not None: @@ -358,18 +453,19 @@ 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) - ass_load_balancers = [ + load_balancers = [ BoundLoadBalancer(self, load_balancer_data) for load_balancer_data in response["load_balancers"] ] - return self._add_meta_to_result(ass_load_balancers, response) + return LoadBalancersPageResult(load_balancers, Meta.parse_meta(response)) - def get_all(self, name=None, label_selector=None): - # type: (Optional[str], Optional[str]) -> List[BoundLoadBalancer] + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + ) -> list[BoundLoadBalancer]: """Get all Load Balancers from this account :param name: str (optional) @@ -378,34 +474,30 @@ def get_all(self, name=None, label_selector=None): Can be used to filter Load Balancers by labels. The response will only contain Load Balancers matching the label selector. :return: List[:class:`BoundLoadBalancer `] """ - return super(LoadBalancersClient, self).get_all( - name=name, label_selector=label_selector - ) + return self._iter_pages(self.get_list, name=name, label_selector=label_selector) - def get_by_name(self, name): - # type: (str) -> BoundLoadBalancer + def get_by_name(self, name: str) -> BoundLoadBalancer | None: """Get Load Balancer by name :param name: str Used to get Load Balancer by name. :return: :class:`BoundLoadBalancer ` """ - return super(LoadBalancersClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - name, # type: str - load_balancer_type, # type: LoadBalancerType - algorithm=None, # type: Optional[LoadBalancerAlgorithm] - services=None, # type: Optional[List[LoadBalancerService]] - targets=None, # type: Optional[List[LoadBalancerTarget]] - labels=None, # type: Optional[Dict[str, str]] - location=None, # type: Optional[Location] - network_zone=None, # type: Optional[str] - public_interface=None, # type: Optional[bool] - network=None, # type: Optional[Union[Network,BoundNetwork]] - ): - # type: (...) -> CreateLoadBalancerResponse: + name: str, + load_balancer_type: LoadBalancerType | BoundLoadBalancerType, + algorithm: LoadBalancerAlgorithm | None = None, + services: list[LoadBalancerService] | None = None, + targets: list[LoadBalancerTarget] | None = None, + labels: dict[str, str] | None = None, + location: Location | BoundLocation | None = None, + network_zone: str | None = None, + public_interface: bool | None = None, + network: Network | BoundNetwork | None = None, + ) -> CreateLoadBalancerResponse: """Creates a Load Balancer . :param name: str @@ -430,7 +522,10 @@ def create( Adds the Load Balancer to a Network :return: :class:`CreateLoadBalancerResponse ` """ - data = {"name": name, "load_balancer_type": load_balancer_type.id_or_name} + data: dict[str, Any] = { + "name": name, + "load_balancer_type": load_balancer_type.id_or_name, + } if network is not None: data["network"] = network.id if public_interface is not None: @@ -440,44 +535,27 @@ def create( if algorithm is not None: data["algorithm"] = {"type": algorithm.type} if services is not None: - service_list = [] - for service in services: - service_list.append(self.get_service_parameters(service)) - data["services"] = service_list - + data["services"] = [service.to_payload() for service in services] if targets is not None: - target_list = [] - for target in targets: - target_data = { - "type": target.type, - "use_private_ip": target.use_private_ip, - } - if target.type == "server": - target_data["server"] = {"id": target.server.id} - elif target.type == "label_selector": - target_data["label_selector"] = { - "selector": target.label_selector.selector - } - elif target.type == "ip": - target_data["ip"] = {"ip": target.ip.ip} - target_list.append(target_data) - - data["targets"] = target_list - + data["targets"] = [target.to_payload() for target in targets] if network_zone is not None: data["network_zone"] = network_zone 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(self, load_balancer, name=None, labels=None): - # type:(LoadBalancer, Optional[str], Optional[Dict[str, str]]) -> BoundLoadBalancer + def update( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundLoadBalancer: """Updates a LoadBalancer. You can update a LoadBalancer’s name and a LoadBalancer’s labels. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -487,92 +565,120 @@ def update(self, load_balancer, name=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundLoadBalancer ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data.update({"name": name}) if labels is not None: data.update({"labels": labels}) response = self._client.request( - url="/load_balancers/{load_balancer_id}".format( - load_balancer_id=load_balancer.id - ), + url=f"{self._base_url}/{load_balancer.id}", method="PUT", json=data, ) return BoundLoadBalancer(self, response["load_balancer"]) - def delete(self, load_balancer): - # type: (LoadBalancer) -> BoundAction + def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: """Deletes a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` :return: boolean """ self._client.request( - url="/load_balancers/{load_balancer_id}".format( - load_balancer_id=load_balancer.id - ), + url=f"{self._base_url}/{load_balancer.id}", method="DELETE", ) return True - def get_actions_list( - self, load_balancer, status=None, sort=None, page=None, per_page=None - ): - # type: (LoadBalancer, Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] - """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 = {} - 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 + def get_metrics( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a LoadBalancer. + + :param load_balancer: The Load Balancer to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + if not isinstance(type, list): + type = [type] + if isinstance(start, str): + start = isoparse(start) + if isinstance(end, str): + end = isoparse(end) + + params: dict[str, Any] = { + "type": ",".join(type), + "start": start.isoformat(), + "end": end.isoformat(), + } + if step is not None: + params["step"] = step response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions".format( - load_balancer_id=load_balancer.id - ), + url=f"{self._base_url}/{load_balancer.id}/metrics", method="GET", params=params, ) - actions = [ - BoundAction(self._client.actions, action_data) - for action_data in response["actions"] - ] - return add_meta_to_result(actions, response, "actions") + return GetMetricsResponse( + metrics=Metrics(**response["metrics"]), + ) - def get_actions(self, load_balancer, status=None, sort=None): - # type: (LoadBalancer, Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a Load Balancer. + def get_actions_list( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + 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 Load Balancer. + + :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, + ) - :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 `] - """ - return super(LoadBalancersClient, self).get_actions( - load_balancer, status=status, sort=sort + def get_actions( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Load Balancer. + + :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, + load_balancer, + status=status, + sort=sort, ) - def add_service(self, load_balancer, service): - # type: (Union[LoadBalancer, BoundLoadBalancer], LoadBalancerService) -> List[BoundAction] + def add_service( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + service: LoadBalancerService, + ) -> BoundAction: """Adds a service to a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -580,84 +686,20 @@ def add_service(self, load_balancer, service): The LoadBalancerService you want to add to the Load Balancer :return: :class:`BoundAction ` """ - data = self.get_service_parameters(service) + data: dict[str, Any] = service.to_payload() response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/add_service".format( - load_balancer_id=load_balancer.id - ), + url=f"{self._base_url}/{load_balancer.id}/actions/add_service", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) - - def get_service_parameters(self, service): - data = {} - if service.protocol is not None: - data["protocol"] = service.protocol - if service.listen_port is not None: - data["listen_port"] = service.listen_port - if service.destination_port is not None: - data["destination_port"] = service.destination_port - if service.proxyprotocol is not None: - data["proxyprotocol"] = service.proxyprotocol - if service.http is not None: - data["http"] = {} - if service.http.cookie_name is not None: - data["http"]["cookie_name"] = service.http.cookie_name - if service.http.cookie_lifetime is not None: - data["http"]["cookie_lifetime"] = service.http.cookie_lifetime - if service.http.redirect_http is not None: - data["http"]["redirect_http"] = service.http.redirect_http - if service.http.sticky_sessions is not None: - data["http"]["sticky_sessions"] = service.http.sticky_sessions - certificate_ids = [] - for certificate in service.http.certificates: - certificate_ids.append(certificate.id) - data["http"]["certificates"] = certificate_ids - if service.health_check is not None: - data["health_check"] = { - "protocol": service.health_check.protocol, - "port": service.health_check.port, - "interval": service.health_check.interval, - "timeout": service.health_check.timeout, - "retries": service.health_check.retries, - } - data["health_check"] = {} - if service.health_check.protocol is not None: - data["health_check"]["protocol"] = service.health_check.protocol - if service.health_check.port is not None: - data["health_check"]["port"] = service.health_check.port - if service.health_check.interval is not None: - data["health_check"]["interval"] = service.health_check.interval - if service.health_check.timeout is not None: - data["health_check"]["timeout"] = service.health_check.timeout - if service.health_check.retries is not None: - data["health_check"]["retries"] = service.health_check.retries - if service.health_check.http is not None: - data["health_check"]["http"] = {} - if service.health_check.http.domain is not None: - data["health_check"]["http"][ - "domain" - ] = service.health_check.http.domain - if service.health_check.http.path is not None: - data["health_check"]["http"][ - "path" - ] = service.health_check.http.path - if service.health_check.http.response is not None: - data["health_check"]["http"][ - "response" - ] = service.health_check.http.response - if service.health_check.http.status_codes is not None: - data["health_check"]["http"][ - "status_codes" - ] = service.health_check.http.status_codes - if service.health_check.http.tls is not None: - data["health_check"]["http"]["tls"] = service.health_check.http.tls - return data - - def update_service(self, load_balancer, service): - # type: (Union[LoadBalancer, BoundLoadBalancer], LoadBalancerService) -> List[BoundAction] + return BoundAction(self._parent.actions, response["action"]) + + def update_service( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + service: LoadBalancerService, + ) -> BoundAction: """Updates a service of an Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -665,18 +707,19 @@ def update_service(self, load_balancer, service): The LoadBalancerService with updated values within for the Load Balancer :return: :class:`BoundAction ` """ - data = self.get_service_parameters(service) + data: dict[str, Any] = service.to_payload() response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/update_service".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, service): - # type: (Union[LoadBalancer, BoundLoadBalancer], LoadBalancerService) -> List[BoundAction] + def delete_service( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + service: LoadBalancerService, + ) -> BoundAction: """Deletes a service from a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -684,21 +727,20 @@ def delete_service(self, load_balancer, service): The LoadBalancerService you want to delete from the Load Balancer :return: :class:`BoundAction ` """ - data = { - "listen_port": service.listen_port, - } + data: dict[str, Any] = {"listen_port": service.listen_port} response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/delete_service".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, target): - # type: (Union[LoadBalancer, BoundLoadBalancer], LoadBalancerTarget) -> List[BoundAction] + def add_target( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + target: LoadBalancerTarget, + ) -> BoundAction: """Adds a target to a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -706,25 +748,20 @@ def add_target(self, load_balancer, target): The LoadBalancerTarget you want to add to the Load Balancer :return: :class:`BoundAction ` """ - data = {"type": target.type, "use_private_ip": target.use_private_ip} - if target.type == "server": - data["server"] = {"id": target.server.id} - elif target.type == "label_selector": - data["label_selector"] = {"selector": target.label_selector.selector} - elif target.type == "ip": - data["ip"] = {"ip": target.ip.ip} + data: dict[str, Any] = target.to_payload() response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/add_target".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, target): - # type: (Union[LoadBalancer, BoundLoadBalancer], LoadBalancerTarget) -> List[BoundAction] + def remove_target( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + target: LoadBalancerTarget, + ) -> BoundAction: """Removes a target from a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -732,27 +769,22 @@ def remove_target(self, load_balancer, target): The LoadBalancerTarget you want to remove from the Load Balancer :return: :class:`BoundAction ` """ - data = { - "type": target.type, - } - if target.type == "server": - data["server"] = {"id": target.server.id} - elif target.type == "label_selector": - data["label_selector"] = {"selector": target.label_selector.selector} - elif target.type == "ip": - data["ip"] = {"ip": target.ip.ip} + data: dict[str, Any] = target.to_payload() + # Do not send use_private_ip on remove_target + data.pop("use_private_ip", None) response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/remove_target".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, algorithm): - # type: (Union[LoadBalancer, BoundLoadBalancer], Optional[bool]) -> BoundAction + def change_algorithm( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + algorithm: LoadBalancerAlgorithm, + ) -> BoundAction: """Changes the algorithm used by the Load Balancer :param load_balancer: :class:` ` or :class:`LoadBalancer ` @@ -760,19 +792,21 @@ def change_algorithm(self, load_balancer, algorithm): The LoadBalancerSubnet you want to add to the Load Balancer :return: :class:`BoundAction ` """ - data = {"type": algorithm.type} + data: dict[str, Any] = {"type": algorithm.type} response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/change_algorithm".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, ip, dns_ptr): - # type: (Union[LoadBalancer, BoundLoadBalancer], str, str) -> BoundAction + def change_dns_ptr( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + 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. :param ip: str @@ -783,16 +817,17 @@ def change_dns_ptr(self, load_balancer, ip, dns_ptr): """ response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/change_dns_ptr".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, delete=None): - # type: (Union[LoadBalancer, BoundLoadBalancer], Optional[bool]) -> BoundAction + def change_protection( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + delete: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of a Load Balancer. :param load_balancer: :class:` ` or :class:`LoadBalancer ` @@ -800,68 +835,70 @@ def change_protection(self, load_balancer, delete=None): If True, prevents the Load Balancer from being deleted :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/change_protection".format( - load_balancer_id=load_balancer.id - ), + 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, # type: Union[LoadBalancer, BoundLoadBalancer] - network, # type: Union[Network, BoundNetwork] - ip=None, # type: Optional[str] - ): + load_balancer: LoadBalancer | BoundLoadBalancer, + network: Network | BoundNetwork, + ip: str | None = None, + ip_range: str | None = None, + ) -> BoundAction: """Attach a Load Balancer to a Network. :param load_balancer: :class:` ` or :class:`LoadBalancer ` :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 = {"network": network.id} + 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="/load_balancers/{load_balancer_id}/actions/attach_to_network".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, network): - # type: (Union[LoadBalancer, BoundLoadBalancer], Union[Network,BoundNetwork]) -> BoundAction + def detach_from_network( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + network: Network | BoundNetwork, + ) -> BoundAction: """Detaches a Load Balancer from a Network. :param load_balancer: :class:` ` or :class:`LoadBalancer ` :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - data = { - "network": network.id, - } + data: dict[str, Any] = {"network": network.id} response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/detach_from_network".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer): - # type: (Union[LoadBalancer, BoundLoadBalancer]) -> BoundAction + def enable_public_interface( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + ) -> BoundAction: """Enables the public interface of a Load Balancer. :param load_balancer: :class:` ` or :class:`LoadBalancer ` @@ -870,15 +907,15 @@ def enable_public_interface(self, load_balancer): """ response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/enable_public_interface".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer): - # type: (Union[LoadBalancer, BoundLoadBalancer]) -> BoundAction + def disable_public_interface( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + ) -> BoundAction: """Disables the public interface of a Load Balancer. :param load_balancer: :class:` ` or :class:`LoadBalancer ` @@ -887,15 +924,16 @@ def disable_public_interface(self, load_balancer): """ response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/disable_public_interface".format( - load_balancer_id=load_balancer.id - ), + 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, load_balancer, load_balancer_type): - # type: ([LoadBalancer, BoundLoadBalancer], [LoadBalancerType, BoundLoadBalancerType]) ->BoundAction + def change_type( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + load_balancer_type: LoadBalancerType | BoundLoadBalancerType, + ) -> BoundAction: """Changes the type of a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` @@ -903,14 +941,10 @@ def change_type(self, load_balancer, load_balancer_type): Load Balancer type the Load Balancer should migrate to :return: :class:`BoundAction ` """ - data = { - "load_balancer_type": load_balancer_type.id_or_name, - } + data: dict[str, Any] = {"load_balancer_type": load_balancer_type.id_or_name} response = self._client.request( - url="/load_balancers/{load_balancer_id}/actions/change_type".format( - load_balancer_id=load_balancer.id - ), + 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 04302924..c02e1100 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -1,380 +1,655 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse - -from hcloud.core.domain import BaseDomain - - -class LoadBalancer(BaseDomain): - """LoadBalancer Domain - - :param id: int - ID of the Load Balancer - :param name: str - Name of the Load Balancer (must be unique per project) - :param created: datetime - Point in time when the Load Balancer was created - :param protection: dict - Protection configuration for the Load Balancer - :param labels: dict - User-defined labels (key-value pairs) - :param location: Location - Location of the Load Balancer - :param public_net: :class:`PublicNetwork ` - Public network information. - :param private_net: List[:class:`PrivateNet ` - :param ipv6: :class:`IPv6Network ` - :param enabled: boolean - """ - - __slots__ = ("ipv4", "ipv6", "enabled") - - def __init__( - self, - ipv4, # type: IPv4Address - ipv6, # type: IPv6Network - enabled, # type: bool - ): - self.ipv4 = ipv4 - self.ipv6 = ipv6 - self.enabled = enabled - - -class IPv4Address(BaseDomain): - """IPv4 Address Domain - - :param ip: str - The IPv4 Address - """ - - __slots__ = ( - "ip", - "dns_ptr", - ) - - def __init__( - self, - ip, # type: str - dns_ptr, # type: str - ): - self.ip = ip - self.dns_ptr = dns_ptr - - -class IPv6Network(BaseDomain): - """IPv6 Network Domain - - :param ip: str - The IPv6 Network as CIDR Notation - """ - - __slots__ = ( - "ip", - "dns_ptr", - ) - - def __init__( - self, - ip, # type: str - dns_ptr, # type: str - ): - self.ip = ip - self.dns_ptr = dns_ptr - - -class PrivateNet(BaseDomain): - """PrivateNet Domain - - :param network: :class:`BoundNetwork ` - The Network the LoadBalancer is attached to - :param ip: str - The main IP Address of the LoadBalancer in the Network - """ - - __slots__ = ( - "network", - "ip", - ) - - def __init__( - self, - network, # type: BoundNetwork - ip, # type: str - ): - self.network = network - self.ip = ip - - -class CreateLoadBalancerResponse(BaseDomain): - """Create Load Balancer Response Domain - - :param load_balancer: :class:`BoundLoadBalancer ` - The created Load Balancer - :param action: :class:`BoundAction ` - Shows the progress of the Load Balancer creation - """ - - __slots__ = ( - "load_balancer", - "action", - ) - - def __init__( - self, - load_balancer, # type: BoundLoadBalancer - action, # type: BoundAction - ): - self.load_balancer = load_balancer - self.action = action +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..certificates import BoundCertificate + from ..load_balancer_types import BoundLoadBalancerType + from ..locations import BoundLocation + from ..metrics import Metrics + 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 + + :param id: int + ID of the Load Balancer + :param name: str + Name of the Load Balancer (must be unique per project) + :param created: datetime + Point in time when the Load Balancer was created + :param protection: dict + Protection configuration for the Load Balancer + :param labels: dict + User-defined labels (key-value pairs) + :param location: Location + Location of the Load Balancer + :param public_net: :class:`PublicNetwork ` + Public network information. + :param private_net: List[:class:`PrivateNet 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 + + :param protocol: str + Protocol of the service Choices: tcp, http, https + :param listen_port: int + Required when protocol is tcp, must be unique per Load Balancer. + :param destination_port: int + Required when protocol is tcp + :param proxyprotocol: bool + Enable proxyprotocol + :param health_check: LoadBalancerHealthCheck + Configuration for health checks + :param http: LoadBalancerServiceHttp + Configuration for http/https protocols, required when protocol is http/https + """ + + def __init__( + self, + protocol: str | None = None, + listen_port: int | None = None, + destination_port: int | None = None, + proxyprotocol: bool | None = None, + health_check: LoadBalancerHealthCheck | None = None, + http: LoadBalancerServiceHttp | None = None, + ): + self.protocol = protocol + self.listen_port = listen_port + self.destination_port = destination_port + self.proxyprotocol = proxyprotocol + self.health_check = health_check + self.http = http + + # pylint: disable=too-many-branches + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + + if self.protocol is not None: + payload["protocol"] = self.protocol + if self.listen_port is not None: + payload["listen_port"] = self.listen_port + if self.destination_port is not None: + payload["destination_port"] = self.destination_port + if self.proxyprotocol is not None: + payload["proxyprotocol"] = self.proxyprotocol + + if self.http is not None: + http: dict[str, Any] = {} + if self.http.cookie_name is not None: + http["cookie_name"] = self.http.cookie_name + if self.http.cookie_lifetime is not None: + http["cookie_lifetime"] = self.http.cookie_lifetime + if self.http.redirect_http is not None: + http["redirect_http"] = self.http.redirect_http + if self.http.sticky_sessions is not None: + http["sticky_sessions"] = self.http.sticky_sessions + + http["certificates"] = [ + certificate.id for certificate in self.http.certificates or [] + ] + + payload["http"] = http + + if self.health_check is not None: + health_check: dict[str, Any] = { + "protocol": self.health_check.protocol, + "port": self.health_check.port, + "interval": self.health_check.interval, + "timeout": self.health_check.timeout, + "retries": self.health_check.retries, + } + if self.health_check.protocol is not None: + health_check["protocol"] = self.health_check.protocol + if self.health_check.port is not None: + health_check["port"] = self.health_check.port + if self.health_check.interval is not None: + health_check["interval"] = self.health_check.interval + if self.health_check.timeout is not None: + health_check["timeout"] = self.health_check.timeout + if self.health_check.retries is not None: + health_check["retries"] = self.health_check.retries + + if self.health_check.http is not None: + health_check_http: dict[str, Any] = {} + if self.health_check.http.domain is not None: + health_check_http["domain"] = self.health_check.http.domain + if self.health_check.http.path is not None: + health_check_http["path"] = self.health_check.http.path + if self.health_check.http.response is not None: + health_check_http["response"] = self.health_check.http.response + if self.health_check.http.status_codes is not None: + health_check_http["status_codes"] = ( + self.health_check.http.status_codes + ) + if self.health_check.http.tls is not None: + health_check_http["tls"] = self.health_check.http.tls + + health_check["http"] = health_check_http + + payload["health_check"] = health_check + return payload + + +class LoadBalancerServiceHttp(BaseDomain): + """LoadBalancerServiceHttp Domain + + :param cookie_name: str + Name of the cookie used for Session Stickness + :param cookie_lifetime: str + Lifetime of the cookie used for Session Stickness + :param certificates: list + IDs of the Certificates to use for TLS/SSL termination by the Load Balancer; empty for TLS/SSL passthrough or if protocol is "http" + :param redirect_http: bool + Redirect traffic from http port 80 to port 443 + :param sticky_sessions: bool + Use sticky sessions. Only available if protocol is "http" or "https". + """ + + __api_properties__ = ( + "cookie_name", + "cookie_lifetime", + "certificates", + "redirect_http", + "sticky_sessions", + ) + __slots__ = __api_properties__ + + def __init__( + self, + cookie_name: str | None = None, + cookie_lifetime: str | None = None, + certificates: list[BoundCertificate] | None = None, + redirect_http: bool | None = None, + sticky_sessions: bool | 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 + + +class LoadBalancerHealthCheck(BaseDomain): + """LoadBalancerHealthCheck Domain + + :param protocol: str + Protocol of the service Choices: tcp, http, https + :param port: int + Port the healthcheck will be performed on + :param interval: int + Interval we trigger health check in + :param timeout: int + 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: LoadBalancerHealthCheckHttp + HTTP Config + """ + + __api_properties__ = ( + "protocol", + "port", + "interval", + "timeout", + "retries", + "http", + ) + __slots__ = __api_properties__ + + def __init__( + self, + protocol: str | None = None, + port: int | None = None, + interval: int | None = None, + timeout: int | None = None, + retries: int | None = None, + http: LoadBalancerHealthCheckHttp | None = None, + ): + self.protocol = protocol + self.port = port + self.interval = interval + self.timeout = timeout + self.retries = retries + self.http = http + + +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 + :param path: str + HTTP Path send in Request + :param response: str + Optional HTTP response to receive in order to pass the health check + :param status_codes: list + List of HTTP status codes to receive in order to pass the health check + :param tls: bool + 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[str] | None = None, + tls: bool | None = None, + ): + self.domain = domain + self.path = path + self.response = response + self.status_codes = status_codes + 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 + + :param type: str + Type of the resource, can be server or label_selector + :param server: Server + Target server + :param label_selector: LoadBalancerTargetLabelSelector + Target label selector + :param ip: LoadBalancerTargetIP + Target IP + :param use_private_ip: bool + 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". + """ + + __api_properties__ = ( + "type", + "server", + "label_selector", + "ip", + "use_private_ip", + "health_status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + type: str | None = None, + server: BoundServer | None = None, + label_selector: LoadBalancerTargetLabelSelector | None = None, + ip: LoadBalancerTargetIP | None = None, + use_private_ip: bool | None = None, + health_status: list[LoadBalancerTargetHealthStatus] | None = None, + ): + self.type = type + self.server = server + self.label_selector = label_selector + self.ip = ip + self.use_private_ip = use_private_ip + self.health_status = health_status + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "type": self.type, + } + if self.use_private_ip is not None: + payload["use_private_ip"] = self.use_private_ip + + if self.type == "server": + if self.server is None: + raise ValueError(f"server is not defined in target {self!r}") + payload["server"] = {"id": self.server.id} + + elif self.type == "label_selector": + if self.label_selector is None: + raise ValueError(f"label_selector is not defined in target {self!r}") + payload["label_selector"] = {"selector": self.label_selector.selector} + + elif self.type == "ip": + if self.ip is None: + raise ValueError(f"ip is not defined in target {self!r}") + payload["ip"] = {"ip": self.ip.ip} + + return payload + + +class LoadBalancerTargetHealthStatus(BaseDomain): + """LoadBalancerTargetHealthStatus Domain + + :param listen_port: Load Balancer Target listen port + :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, + status: str | None = None, + ): + self.listen_port = listen_port + self.status = status + + +class LoadBalancerTargetLabelSelector(BaseDomain): + """LoadBalancerTargetLabelSelector Domain + + :param selector: str Target label selector + """ + + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + + def __init__(self, selector: str | None = None): + self.selector = selector + + +class LoadBalancerTargetIP(BaseDomain): + """LoadBalancerTargetIP Domain + + :param ip: str Target IP + """ + + __api_properties__ = ("ip",) + __slots__ = __api_properties__ + + def __init__(self, ip: str | None = None): + self.ip = ip + + +class LoadBalancerAlgorithm(BaseDomain): + """LoadBalancerAlgorithm Domain + + :param type: str + 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 + + +class PublicNetwork(BaseDomain): + """Public Network Domain + + :param ipv4: :class:`IPv4Address ` + :param ipv6: :class:`IPv6Network ` + :param enabled: boolean + """ + + __api_properties__ = ("ipv4", "ipv6", "enabled") + __slots__ = __api_properties__ + + def __init__( + self, + ipv4: IPv4Address, + ipv6: IPv6Network, + enabled: bool, + ): + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.enabled = enabled + + +class IPv4Address(BaseDomain): + """IPv4 Address Domain + + :param ip: str + The IPv4 Address + """ + + __api_properties__ = ("ip", "dns_ptr") + __slots__ = __api_properties__ + + def __init__( + self, + ip: str, + dns_ptr: str, + ): + self.ip = ip + self.dns_ptr = dns_ptr + + +class IPv6Network(BaseDomain): + """IPv6 Network Domain + + :param ip: str + The IPv6 Network as CIDR Notation + """ + + __api_properties__ = ("ip", "dns_ptr") + __slots__ = __api_properties__ + + def __init__( + self, + ip: str, + dns_ptr: str, + ): + self.ip = ip + self.dns_ptr = dns_ptr + + +class PrivateNet(BaseDomain): + """PrivateNet Domain + + :param network: :class:`BoundNetwork ` + The Network the LoadBalancer is attached to + :param ip: str + The main IP Address of the LoadBalancer in the Network + """ + + __api_properties__ = ("network", "ip") + __slots__ = __api_properties__ + + def __init__( + self, + network: BoundNetwork, + ip: str, + ): + self.network = network + self.ip = ip + + +class CreateLoadBalancerResponse(BaseDomain): + """Create Load Balancer Response Domain + + :param load_balancer: :class:`BoundLoadBalancer ` + The created Load Balancer + :param action: :class:`BoundAction ` + Shows the progress of the Load Balancer creation + """ + + __api_properties__ = ("load_balancer", "action") + __slots__ = __api_properties__ + + def __init__( + self, + load_balancer: BoundLoadBalancer, + action: BoundAction, + ): + self.load_balancer = load_balancer + self.action = action + + +MetricsType = Literal[ + "open_connections", + "connections_per_second", + "requests_per_second", + "bandwidth", +] + + +class GetMetricsResponse(BaseDomain): + """Get a Load Balancer Metrics Response Domain + + :param metrics: The Load Balancer metrics + """ + + __api_properties__ = ("metrics",) + __slots__ = __api_properties__ + + def __init__( + self, + metrics: Metrics, + ): + self.metrics = metrics diff --git a/hcloud/locations/__init__.py b/hcloud/locations/__init__.py index 40a96afc..f59c2644 100644 --- a/hcloud/locations/__init__.py +++ b/hcloud/locations/__init__.py @@ -1 +1,11 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 e3e0ffa1..5a71c0f6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -1,30 +1,46 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.locations.domain import Location +from typing import Any, NamedTuple +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import Location + +__all__ = [ + "BoundLocation", + "LocationsPageResult", + "LocationsClient", +] + + +class BoundLocation(BoundModelBase[Location], Location): + _client: LocationsClient -class BoundLocation(BoundModelBase): model = Location -class LocationsClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "locations" +class LocationsPageResult(NamedTuple): + locations: list[BoundLocation] + meta: Meta + + +class LocationsClient(ResourceClientBase): + _base_url = "/locations" - def get_by_id(self, id): - # type: (int) -> locations.client.BoundLocation + def get_by_id(self, id: int) -> BoundLocation: """Get a specific location by its ID. :param id: int :return: :class:`BoundLocation ` """ - response = self._client.request( - url="/locations/{location_id}".format(location_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundLocation(self, response["location"]) - def get_list(self, name=None, page=None, per_page=None): - # type: (Optional[str], Optional[int], Optional[int]) -> PageResult[List[BoundLocation], Meta] + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> LocationsPageResult: """Get a list of locations :param name: str (optional) @@ -35,7 +51,7 @@ def get_list(self, name=None, page=None, per_page=None): Specifies how many results are returned by page :return: (List[:class:`BoundLocation `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if page is not None: @@ -43,29 +59,27 @@ def get_list(self, name=None, page=None, per_page=None): 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"] ] - return self._add_meta_to_result(locations, response) + return LocationsPageResult(locations, Meta.parse_meta(response)) - def get_all(self, name=None): - # type: (Optional[str]) -> List[BoundLocation] + def get_all(self, name: str | None = None) -> list[BoundLocation]: """Get all locations :param name: str (optional) Can be used to filter locations by their name. :return: List[:class:`BoundLocation `] """ - return super(LocationsClient, self).get_all(name=name) + return self._iter_pages(self.get_list, name=name) - def get_by_name(self, name): - # type: (str) -> BoundLocation + def get_by_name(self, name: str) -> BoundLocation | None: """Get location by name :param name: str Used to get location by name. :return: :class:`BoundLocation ` """ - return super(LocationsClient, self).get_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 ee0bb45c..b33b157b 100644 --- a/hcloud/locations/domain.py +++ b/hcloud/locations/domain.py @@ -1,5 +1,10 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from __future__ import annotations + +from ..core import BaseDomain, DomainIdentityMixin + +__all__ = [ + "Location", +] class Location(BaseDomain, DomainIdentityMixin): @@ -23,7 +28,7 @@ class Location(BaseDomain, DomainIdentityMixin): Name of network zone this location resides in """ - __slots__ = ( + __api_properties__ = ( "id", "name", "description", @@ -33,17 +38,18 @@ class Location(BaseDomain, DomainIdentityMixin): "longitude", "network_zone", ) + __slots__ = __api_properties__ def __init__( self, - id=None, - name=None, - description=None, - country=None, - city=None, - latitude=None, - longitude=None, - network_zone=None, + id: int | None = None, + name: str | None = None, + description: str | None = None, + country: str | None = None, + city: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + network_zone: str | None = None, ): self.id = id self.name = name diff --git a/hcloud/metrics/__init__.py b/hcloud/metrics/__init__.py new file mode 100644 index 00000000..49555d10 --- /dev/null +++ b/hcloud/metrics/__init__.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from .domain import Metrics, TimeSeries + +__all__ = [ + "Metrics", + "TimeSeries", +] diff --git a/hcloud/metrics/domain.py b/hcloud/metrics/domain.py new file mode 100644 index 00000000..3213e279 --- /dev/null +++ b/hcloud/metrics/domain.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from ..core import BaseDomain + +__all__ = [ + "TimeSeries", + "Metrics", +] + + +TimeSeries = dict[str, dict[Literal["values"], list[tuple[float, str]]]] + + +class Metrics(BaseDomain): + """Metrics Domain + + :param start: Start of period of metrics reported. + :param end: End of period of metrics reported. + :param step: Resolution of results in seconds. + :param time_series: Dict with time series data, using the name of the time series as + key. The metrics timestamps and values are stored in a list of tuples + ``[(timestamp, value), ...]``. + """ + + start: datetime + end: datetime + step: float + time_series: TimeSeries + + __api_properties__ = ( + "start", + "end", + "step", + "time_series", + ) + __slots__ = __api_properties__ + + def __init__( + self, + start: str, + end: str, + step: float, + time_series: TimeSeries, + ): + 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 40a96afc..f49b9934 100644 --- a/hcloud/networks/__init__.py +++ b/hcloud/networks/__init__.py @@ -1 +1,21 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 5adf41b2..73b33e5d 100644 --- a/hcloud/networks/client.py +++ b/hcloud/networks/client.py @@ -1,15 +1,40 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result +from __future__ import annotations -from hcloud.actions.client import BoundAction -from hcloud.networks.domain import Network, NetworkRoute, NetworkSubnet +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 Network, NetworkRoute, NetworkSubnet + +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundNetwork", + "NetworksPageResult", + "NetworksClient", +] + + +class BoundNetwork(BoundModelBase[Network], Network): + _client: NetworksClient -class BoundNetwork(BoundModelBase): model = Network - def __init__(self, client, data, complete=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] @@ -20,68 +45,87 @@ def __init__(self, client, data, complete=True): routes = [NetworkRoute.from_dict(route) for route in routes] data["routes"] = routes - from hcloud.servers.client import BoundServer + # pylint: disable=import-outside-toplevel + from ..servers import BoundServer 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 - super(BoundNetwork, self).__init__(client, data, complete) + super().__init__(client, data, complete) - def update(self, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundNetwork + def update( + self, + name: str | None = None, + expose_routes_to_vswitch: bool | None = None, + labels: dict[str, str] | None = None, + ) -> BoundNetwork: """Updates a network. You can update a network’s name and a networks’s labels. :param name: str (optional) New name to set + :param expose_routes_to_vswitch: Optional[bool] + Indicates if the routes from this network should be exposed to the vSwitch connection. + The exposing only takes effect if a vSwitch connection is active. :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) :return: :class:`BoundNetwork ` """ - return self._client.update(self, name, labels) + return self._client.update( + self, + name=name, + expose_routes_to_vswitch=expose_routes_to_vswitch, + labels=labels, + ) - def delete(self): - # type: () -> BoundAction + def delete(self) -> bool: """Deletes a network. :return: boolean """ return self._client.delete(self) - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a network. + 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 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=None, sort=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a network. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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): - # type: (NetworkSubnet) -> List[BoundAction] + def add_subnet(self, subnet: NetworkSubnet) -> BoundAction: """Adds a subnet entry to a network. :param subnet: :class:`NetworkSubnet ` @@ -90,8 +134,7 @@ def add_subnet(self, subnet): """ return self._client.add_subnet(self, subnet=subnet) - def delete_subnet(self, subnet): - # type: (NetworkSubnet) -> List[BoundAction] + def delete_subnet(self, subnet: NetworkSubnet) -> BoundAction: """Removes a subnet entry from a network :param subnet: :class:`NetworkSubnet ` @@ -100,8 +143,7 @@ def delete_subnet(self, subnet): """ return self._client.delete_subnet(self, subnet=subnet) - def add_route(self, route): - # type: (NetworkRoute) -> List[BoundAction] + def add_route(self, route: NetworkRoute) -> BoundAction: """Adds a route entry to a network. :param route: :class:`NetworkRoute ` @@ -110,8 +152,7 @@ def add_route(self, route): """ return self._client.add_route(self, route=route) - def delete_route(self, route): - # type: (NetworkRoute) -> List[BoundAction] + def delete_route(self, route: NetworkRoute) -> BoundAction: """Removes a route entry to a network. :param route: :class:`NetworkRoute ` @@ -120,8 +161,7 @@ def delete_route(self, route): """ return self._client.delete_route(self, route=route) - def change_ip_range(self, ip_range): - # type: (str) -> List[BoundAction] + def change_ip_range(self, ip_range: str) -> BoundAction: """Changes the IP range of a network. :param ip_range: str @@ -130,8 +170,7 @@ def change_ip_range(self, ip_range): """ return self._client.change_ip_range(self, ip_range=ip_range) - def change_protection(self, delete=None): - # type: (Optional[bool]) -> BoundAction + def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of a network. :param delete: boolean @@ -141,29 +180,43 @@ def change_protection(self, delete=None): return self._client.change_protection(self, delete=delete) -class NetworksClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "networks" +class NetworksPageResult(NamedTuple): + networks: list[BoundNetwork] + meta: Meta + + +class NetworksClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/networks" + + actions: ResourceActionsClient + """Networks scoped actions client - def get_by_id(self, id): - # type: (int) -> BoundNetwork + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_by_id(self, id: int) -> BoundNetwork: """Get a specific network :param id: int - :return: :class:`BoundNetwork + :return: :class:`BoundNetwork ` """ - response = self._client.request( - url="/networks/{network_id}".format(network_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundNetwork(self, response["network"]) def get_list( self, - name=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundNetwork], Meta] + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> NetworksPageResult: """Get a list of networks from this account :param name: str (optional) @@ -176,7 +229,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundNetwork `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if label_selector is not None: @@ -186,15 +239,18 @@ 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) - ass_networks = [ + networks = [ BoundNetwork(self, network_data) for network_data in response["networks"] ] - return self._add_meta_to_result(ass_networks, response) + return NetworksPageResult(networks, Meta.parse_meta(response)) - def get_all(self, name=None, label_selector=None): - # type: (Optional[str], Optional[str]) -> List[BoundNetwork] + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + ) -> list[BoundNetwork]: """Get all networks from this account :param name: str (optional) @@ -203,28 +259,26 @@ def get_all(self, name=None, label_selector=None): Can be used to filter networks by labels. The response will only contain networks matching the label selector. :return: List[:class:`BoundNetwork `] """ - return super(NetworksClient, self).get_all( - name=name, label_selector=label_selector - ) + return self._iter_pages(self.get_list, name=name, label_selector=label_selector) - def get_by_name(self, name): - # type: (str) -> BoundNetwork + def get_by_name(self, name: str) -> BoundNetwork | None: """Get network by name :param name: str Used to get network by name. :return: :class:`BoundNetwork ` """ - return super(NetworksClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - name, # type: str - ip_range, # type: str - subnets=None, # type: Optional[List[NetworkSubnet]] - routes=None, # type: Optional[List[NetworkRoute]] - labels=None, # type: Optional[Dict[str, str]] - ): + name: str, + ip_range: str, + subnets: list[NetworkSubnet] | None = None, + routes: list[NetworkRoute] | None = None, + expose_routes_to_vswitch: bool | None = None, + labels: dict[str, str] | None = None, + ) -> BoundNetwork: """Creates a network with range ip_range. :param name: str @@ -235,122 +289,139 @@ def create( Array of subnets allocated :param routes: List[:class:`NetworkRoute `] Array of routes set in this network + :param expose_routes_to_vswitch: Optional[bool] + Indicates if the routes from this network should be exposed to the vSwitch connection. + The exposing only takes effect if a vSwitch connection is active. :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) :return: :class:`BoundNetwork ` """ - data = {"name": name, "ip_range": ip_range} + data: dict[str, Any] = {"name": name, "ip_range": ip_range} if subnets is not None: - data["subnets"] = [ - { + data_subnets = [] + for subnet in subnets: + data_subnet: dict[str, Any] = { "type": subnet.type, "ip_range": subnet.ip_range, "network_zone": subnet.network_zone, } - for subnet in subnets - ] + if subnet.vswitch_id is not None: + data_subnet["vswitch_id"] = subnet.vswitch_id + + data_subnets.append(data_subnet) + data["subnets"] = data_subnets + if routes is not None: data["routes"] = [ {"destination": route.destination, "gateway": route.gateway} for route in routes ] + + if expose_routes_to_vswitch is not None: + data["expose_routes_to_vswitch"] = expose_routes_to_vswitch + 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"]) - def update(self, network, name=None, labels=None): - # type:(Network, Optional[str], Optional[Dict[str, str]]) -> BoundNetwork + def update( + self, + network: Network | BoundNetwork, + name: str | None = None, + expose_routes_to_vswitch: bool | None = None, + labels: dict[str, str] | None = None, + ) -> BoundNetwork: """Updates a network. You can update a network’s name and a network’s labels. :param network: :class:`BoundNetwork ` or :class:`Network ` :param name: str (optional) New name to set + :param expose_routes_to_vswitch: Optional[bool] + Indicates if the routes from this network should be exposed to the vSwitch connection. + The exposing only takes effect if a vSwitch connection is active. :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) :return: :class:`BoundNetwork ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data.update({"name": name}) + + if expose_routes_to_vswitch is not None: + data["expose_routes_to_vswitch"] = expose_routes_to_vswitch + if labels is not None: data.update({"labels": labels}) + response = self._client.request( - url="/networks/{network_id}".format(network_id=network.id), + url=f"{self._base_url}/{network.id}", method="PUT", json=data, ) return BoundNetwork(self, response["network"]) - def delete(self, network): - # type: (Network) -> BoundAction + def delete(self, network: Network | BoundNetwork) -> bool: """Deletes a network. :param network: :class:`BoundNetwork ` or :class:`Network ` :return: boolean """ - self._client.request( - url="/networks/{network_id}".format(network_id=network.id), method="DELETE" - ) + self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") return True def get_actions_list( - self, network, status=None, sort=None, page=None, per_page=None - ): - # type: (Network, Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] - """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 `) + self, + network: Network | BoundNetwork, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: """ - params = {} - 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="/networks/{network_id}/actions".format(network_id=network.id), - 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 add_meta_to_result(actions, response, "actions") - def get_actions(self, network, status=None, sort=None): - # type: (Network, Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a network. + def get_actions( + self, + network: Network | BoundNetwork, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(NetworksClient, self).get_actions( - network, status=status, sort=sort + return self._iter_pages( + self.get_actions_list, + network, + status=status, + sort=sort, ) - def add_subnet(self, network, subnet): - # type: (Union[Network, BoundNetwork], NetworkSubnet) -> List[BoundAction] + def add_subnet( + self, + network: Network | BoundNetwork, + subnet: NetworkSubnet, + ) -> BoundAction: """Adds a subnet entry to a network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -358,7 +429,7 @@ def add_subnet(self, network, subnet): The NetworkSubnet you want to add to the Network :return: :class:`BoundAction ` """ - data = { + data: dict[str, Any] = { "type": subnet.type, "network_zone": subnet.network_zone, } @@ -368,16 +439,17 @@ def add_subnet(self, network, subnet): data["vswitch_id"] = subnet.vswitch_id response = self._client.request( - url="/networks/{network_id}/actions/add_subnet".format( - network_id=network.id - ), + 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, network, subnet): - # type: (Union[Network, BoundNetwork], NetworkSubnet) -> List[BoundAction] + def delete_subnet( + self, + network: Network | BoundNetwork, + subnet: NetworkSubnet, + ) -> BoundAction: """Removes a subnet entry from a network :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -385,21 +457,20 @@ def delete_subnet(self, network, subnet): The NetworkSubnet you want to remove from the Network :return: :class:`BoundAction ` """ - data = { - "ip_range": subnet.ip_range, - } + data: dict[str, Any] = {"ip_range": subnet.ip_range} response = self._client.request( - url="/networks/{network_id}/actions/delete_subnet".format( - network_id=network.id - ), + 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, network, route): - # type: (Union[Network, BoundNetwork], NetworkRoute) -> List[BoundAction] + def add_route( + self, + network: Network | BoundNetwork, + route: NetworkRoute, + ) -> BoundAction: """Adds a route entry to a network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -407,22 +478,23 @@ def add_route(self, network, route): The NetworkRoute you want to add to the Network :return: :class:`BoundAction ` """ - data = { + data: dict[str, Any] = { "destination": route.destination, "gateway": route.gateway, } response = self._client.request( - url="/networks/{network_id}/actions/add_route".format( - network_id=network.id - ), + 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, network, route): - # type: (Union[Network, BoundNetwork], NetworkRoute) -> List[BoundAction] + def delete_route( + self, + network: Network | BoundNetwork, + route: NetworkRoute, + ) -> BoundAction: """Removes a route entry to a network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -430,22 +502,23 @@ def delete_route(self, network, route): The NetworkRoute you want to remove from the Network :return: :class:`BoundAction ` """ - data = { + data: dict[str, Any] = { "destination": route.destination, "gateway": route.gateway, } response = self._client.request( - url="/networks/{network_id}/actions/delete_route".format( - network_id=network.id - ), + 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, network, ip_range): - # type: (Union[Network, BoundNetwork], str) -> List[BoundAction] + def change_ip_range( + self, + network: Network | BoundNetwork, + ip_range: str, + ) -> BoundAction: """Changes the IP range of a network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -453,21 +526,20 @@ def change_ip_range(self, network, ip_range): The new prefix for the whole network. :return: :class:`BoundAction ` """ - data = { - "ip_range": ip_range, - } + data: dict[str, Any] = {"ip_range": ip_range} response = self._client.request( - url="/networks/{network_id}/actions/change_ip_range".format( - network_id=network.id - ), + 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, network, delete=None): - # type: (Union[Network, BoundNetwork], Optional[bool]) -> BoundAction + def change_protection( + self, + network: Network | BoundNetwork, + delete: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of a network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -475,15 +547,13 @@ def change_protection(self, network, delete=None): If True, prevents the network from being deleted :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) response = self._client.request( - url="/networks/{network_id}/actions/change_protection".format( - network_id=network.id - ), + 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 8fc2c4c9..80c3badc 100644 --- a/hcloud/networks/domain.py +++ b/hcloud/networks/domain.py @@ -1,10 +1,25 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain +import warnings +from typing import TYPE_CHECKING, TypedDict +from ..core import BaseDomain, DomainIdentityMixin -class Network(BaseDomain): +if TYPE_CHECKING: + from ..actions import BoundAction + from ..servers import BoundServer + from .client import BoundNetwork + +__all__ = [ + "Network", + "NetworkProtection", + "NetworkSubnet", + "NetworkRoute", + "CreateNetworkResponse", +] + + +class Network(BaseDomain, DomainIdentityMixin): """Network Domain :param id: int @@ -17,6 +32,8 @@ class Network(BaseDomain): Subnets allocated in this network :param routes: List[:class:`NetworkRoute `] Routes set in this network + :param expose_routes_to_vswitch: bool + Indicates if the routes from this network should be exposed to the vSwitch connection. :param servers: List[:class:`BoundServer `] Servers attached to this network :param protection: dict @@ -25,41 +42,49 @@ class Network(BaseDomain): User-defined labels (key-value pairs) """ - __slots__ = ( + __api_properties__ = ( "id", "name", "ip_range", "subnets", "routes", + "expose_routes_to_vswitch", "servers", "protection", "labels", "created", ) + __slots__ = __api_properties__ def __init__( self, - id, - name=None, - created=None, - ip_range=None, - subnets=None, - routes=None, - servers=None, - protection=None, - labels=None, + id: int, + name: str | None = None, + created: str | None = None, + ip_range: str | None = None, + subnets: list[NetworkSubnet] | None = None, + routes: list[NetworkRoute] | None = None, + expose_routes_to_vswitch: bool | None = None, + servers: list[BoundServer] | 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 + self.expose_routes_to_vswitch = expose_routes_to_vswitch self.servers = servers self.protection = protection self.labels = labels +class NetworkProtection(TypedDict): + delete: bool + + class NetworkSubnet(BaseDomain): """Network Subnet Domain @@ -75,16 +100,42 @@ 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, ip_range, type=None, network_zone=None, gateway=None, vswitch_id=None + self, + ip_range: str, + type: str | None = None, + network_zone: str | None = None, + gateway: str | None = None, + vswitch_id: int | None = None, ): self.type = type self.ip_range = ip_range @@ -102,9 +153,10 @@ class NetworkRoute(BaseDomain): Gateway for the route. """ - __slots__ = ("destination", "gateway") + __api_properties__ = ("destination", "gateway") + __slots__ = __api_properties__ - def __init__(self, destination, gateway): + def __init__(self, destination: str, gateway: str): self.destination = destination self.gateway = gateway @@ -118,12 +170,13 @@ 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, - network, # type: BoundNetwork - action, # type: BoundAction + network: BoundNetwork, + action: BoundAction, ): self.network = network self.action = action diff --git a/hcloud/placement_groups/__init__.py b/hcloud/placement_groups/__init__.py index 40a96afc..ab82da5a 100644 --- a/hcloud/placement_groups/__init__.py +++ b/hcloud/placement_groups/__init__.py @@ -1 +1,16 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundPlacementGroup, + PlacementGroupsClient, + PlacementGroupsPageResult, +) +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 932edc5e..041118d8 100644 --- a/hcloud/placement_groups/client.py +++ b/hcloud/placement_groups/client.py @@ -1,15 +1,28 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.placement_groups.domain import PlacementGroup, CreatePlacementGroupResponse +from typing import Any, NamedTuple +from ..actions import BoundAction +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import CreatePlacementGroupResponse, PlacementGroup + +__all__ = [ + "BoundPlacementGroup", + "PlacementGroupsPageResult", + "PlacementGroupsClient", +] + + +class BoundPlacementGroup(BoundModelBase[PlacementGroup], PlacementGroup): + _client: PlacementGroupsClient -class BoundPlacementGroup(BoundModelBase): model = PlacementGroup - def update(self, labels=None, name=None): - # type: (Optional[str], Optional[Dict[str, str]], Optional[str]) -> BoundPlacementGroup + def update( + self, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundPlacementGroup: """Updates the name or labels of a Placement Group :param labels: Dict[str, str] (optional) @@ -18,10 +31,9 @@ def update(self, labels=None, name=None): 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): - # type: () -> bool + def delete(self) -> bool: """Deletes a Placement Group :return: boolean @@ -29,32 +41,35 @@ def delete(self): return self._client.delete(self) -class PlacementGroupsClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "placement_groups" +class PlacementGroupsPageResult(NamedTuple): + placement_groups: list[BoundPlacementGroup] + meta: Meta + + +class PlacementGroupsClient(ResourceClientBase): + _base_url = "/placement_groups" - def get_by_id(self, id): - # type: (int) -> BoundPlacementGroup + def get_by_id(self, id: int) -> BoundPlacementGroup: """Returns a specific Placement Group object :param id: int :return: :class:`BoundPlacementGroup ` """ response = self._client.request( - url="/placement_groups/{placement_group_id}".format(placement_group_id=id), + url=f"{self._base_url}/{id}", method="GET", ) return BoundPlacementGroup(self, response["placement_group"]) def get_list( self, - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - name=None, # type: Optional[str] - sort=None, # type: Optional[List[str]] - type=None, # type: Optional[str] - ): - # type: (...) -> PageResults[List[BoundPlacementGroup]] + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + name: str | None = None, + sort: list[str] | None = None, + type: str | None = None, + ) -> PlacementGroupsPageResult: """Get a list of Placement Groups :param label_selector: str (optional) @@ -70,7 +85,7 @@ def get_list( :return: (List[:class:`BoundPlacementGroup `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if label_selector is not None: params["label_selector"] = label_selector @@ -84,18 +99,20 @@ 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"] ] - return self._add_meta_to_result(placement_groups, response) + return PlacementGroupsPageResult(placement_groups, Meta.parse_meta(response)) - def get_all(self, label_selector=None, name=None, sort=None): - # type: (Optional[str], Optional[str], Optional[List[str]]) -> List[BoundPlacementGroup] + def get_all( + self, + label_selector: str | None = None, + name: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundPlacementGroup]: """Get all Placement Groups :param label_selector: str (optional) @@ -106,27 +123,28 @@ def get_all(self, label_selector=None, name=None, sort=None): Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) :return: List[:class:`BoundPlacementGroup `] """ - return super(PlacementGroupsClient, self).get_all( - label_selector=label_selector, name=name, sort=sort + return self._iter_pages( + self.get_list, + label_selector=label_selector, + name=name, + sort=sort, ) - def get_by_name(self, name): - # type: (str) -> BoundPlacementGroup + def get_by_name(self, name: str) -> BoundPlacementGroup | None: """Get Placement Group by name :param name: str Used to get Placement Group by name :return: class:`BoundPlacementGroup ` """ - return super(PlacementGroupsClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - name, # type: str - type, # type: str - labels=None, # type: Optional[Dict[str, str]] - ): - # type: (...) -> CreatePlacementGroupResponse + name: str, + type: str, + labels: dict[str, str] | None = None, + ) -> CreatePlacementGroupResponse: """Creates a new Placement Group. :param name: str @@ -138,16 +156,14 @@ def create( :return: :class:`CreatePlacementGroupResponse ` """ - data = {"name": name, "type": type} + 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.action, response["action"]) + action = BoundAction(self._parent.actions, response["action"]) result = CreatePlacementGroupResponse( placement_group=BoundPlacementGroup(self, response["placement_group"]), @@ -155,8 +171,12 @@ def create( ) return result - def update(self, placement_group, labels=None, name=None): - # type: (PlacementGroup, Optional[Dict[str, str]], Optional[str]) -> BoundPlacementGroup + def update( + self, + placement_group: PlacementGroup | BoundPlacementGroup, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundPlacementGroup: """Updates the description or labels of a Placement Group. :param placement_group: :class:`BoundPlacementGroup ` or :class:`PlacementGroup ` @@ -167,32 +187,27 @@ def update(self, placement_group, labels=None, name=None): :return: :class:`BoundPlacementGroup ` """ - data = {} + data: dict[str, Any] = {} if labels is not None: data["labels"] = labels if name is not None: data["name"] = name response = self._client.request( - url="/placement_groups/{placement_group_id}".format( - placement_group_id=placement_group.id - ), + url=f"{self._base_url}/{placement_group.id}", method="PUT", json=data, ) return BoundPlacementGroup(self, response["placement_group"]) - def delete(self, placement_group): - # type: (PlacementGroup) -> bool + def delete(self, placement_group: PlacementGroup | BoundPlacementGroup) -> bool: """Deletes a Placement Group. :param placement_group: :class:`BoundPlacementGroup ` or :class:`PlacementGroup ` :return: boolean """ self._client.request( - url="/placement_groups/{placement_group_id}".format( - placement_group_id=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 0e4e8f5a..1dc9b9dd 100644 --- a/hcloud/placement_groups/domain.py +++ b/hcloud/placement_groups/domain.py @@ -1,10 +1,20 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain +from typing import TYPE_CHECKING +from ..core import BaseDomain, DomainIdentityMixin -class PlacementGroup(BaseDomain): +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundPlacementGroup + +__all__ = [ + "PlacementGroup", + "CreatePlacementGroupResponse", +] + + +class PlacementGroup(BaseDomain, DomainIdentityMixin): """Placement Group Domain :param id: int @@ -21,7 +31,8 @@ class PlacementGroup(BaseDomain): 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 @@ -29,14 +40,20 @@ class PlacementGroup(BaseDomain): TYPE_SPREAD = "spread" def __init__( - self, id=None, name=None, labels=None, servers=None, type=None, created=None + self, + id: int | None = None, + name: str | None = None, + labels: dict[str, str] | None = None, + servers: list[int] | None = None, + type: str | None = None, + created: str | None = None, ): self.id = id self.name = name 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): @@ -48,12 +65,13 @@ 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, - placement_group, # type: BoundPlacementGroup - action, # type: BoundAction + placement_group: BoundPlacementGroup, + action: BoundAction | None, ): self.placement_group = placement_group self.action = action diff --git a/hcloud/primary_ips/__init__.py b/hcloud/primary_ips/__init__.py new file mode 100644 index 00000000..99974046 --- /dev/null +++ b/hcloud/primary_ips/__init__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +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 new file mode 100644 index 00000000..6d6693dc --- /dev/null +++ b/hcloud/primary_ips/client.py @@ -0,0 +1,488 @@ +from __future__ import annotations + +import warnings +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 CreatePrimaryIPResponse, PrimaryIP + +if TYPE_CHECKING: + from .._client import Client + from ..datacenters import BoundDatacenter, Datacenter + from ..locations import BoundLocation, Location + + +__all__ = [ + "BoundPrimaryIP", + "PrimaryIPsPageResult", + "PrimaryIPsClient", +] + + +class BoundPrimaryIP(BoundModelBase[PrimaryIP], PrimaryIP): + _client: PrimaryIPsClient + + model = PrimaryIP + + 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 + + raw = data.get("datacenter", {}) + if raw: + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) + + raw = data.get("location", {}) + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) + + super().__init__(client, data, complete) + + 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, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundPrimaryIP: + """Updates the description or labels of a Primary IP. + + :param auto_delete: bool (optional) + Auto delete IP when assignee gets deleted + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param name: str (optional) + New Name to set + :return: :class:`BoundPrimaryIP ` + """ + return self._client.update( + self, + auto_delete=auto_delete, + labels=labels, + name=name, + ) + + def delete(self) -> bool: + """Deletes a Primary IP. If it is currently assigned to a server it will automatically get unassigned. + + :return: boolean + """ + return self._client.delete(self) + + def change_protection(self, delete: bool | None = None) -> BoundAction: + """Changes the protection configuration of the Primary IP. + + :param delete: boolean + If true, prevents the Primary IP from being deleted + :return: :class:`BoundAction ` + """ + 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. + + :param assignee_id: int` + Id of an assignee the Primary IP shall be assigned to + :param assignee_type: string` + Assignee type (e.g server) the Primary IP shall be assigned to + :return: :class:`BoundAction ` + """ + 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. + + :return: :class:`BoundAction ` + """ + return self._client.unassign(self) + + def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: + """Changes the hostname that will appear when getting the hostname belonging to this Primary IP. + + :param ip: str + The IP address for which to set the reverse DNS entry + :param dns_ptr: str + 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=ip, dns_ptr=dns_ptr) + + +class PrimaryIPsPageResult(NamedTuple): + primary_ips: list[BoundPrimaryIP] + meta: Meta + + +class PrimaryIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/primary_ips" + + actions: ResourceActionsClient + """Primary IPs scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_actions_list( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + status: list[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. + + :param id: int + :return: :class:`BoundPrimaryIP ` + """ + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundPrimaryIP(self, response["primary_ip"]) + + def get_list( + self, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + name: str | None = None, + ip: str | None = None, + ) -> PrimaryIPsPageResult: + """Get a list of primary ips from this account + + :param label_selector: str (optional) + Can be used to filter Primary IPs by labels. The response will only contain Primary IPs matching the label selectorable values. + :param page: int (optional) + Specifies the page to fetch + :param per_page: int (optional) + Specifies how many results are returned by page + :param name: str (optional) + Can be used to filter networks by their name. + :param ip: str (optional) + Can be used to filter resources by their ip. The response will only contain the resources matching the specified ip. + :return: (List[:class:`BoundPrimaryIP `], :class:`Meta `) + """ + params: dict[str, Any] = {} + + 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 name is not None: + params["name"] = name + if ip is not None: + params["ip"] = ip + + 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"] + ] + + return PrimaryIPsPageResult(primary_ips, Meta.parse_meta(response)) + + def get_all( + self, + label_selector: str | None = None, + name: str | None = None, + ) -> list[BoundPrimaryIP]: + """Get all primary ips from this account + + :param label_selector: str (optional) + Can be used to filter Primary IPs by labels. The response will only contain Primary IPs matching the label selector.able values. + :param name: str (optional) + Can be used to filter networks by their name. + :return: List[:class:`BoundPrimaryIP `] + """ + return self._iter_pages(self.get_list, label_selector=label_selector, name=name) + + def get_by_name(self, name: str) -> BoundPrimaryIP | None: + """Get Primary IP by name + + :param name: str + Used to get Primary IP by name. + :return: :class:`BoundPrimaryIP ` + """ + return self._get_first_by(self.get_list, name=name) + + def create( + self, + type: str, + name: str, + datacenter: Datacenter | BoundDatacenter | None = None, + location: Location | BoundLocation | None = None, + assignee_type: str | None = "server", + assignee_id: int | None = None, + auto_delete: bool | None = False, + 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 name: str + :param datacenter: Datacenter (optional) + :param location: Location (optional) + :param assignee_type: str (optional) + :param assignee_id: int (optional) + :param auto_delete: bool (optional) + :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) + :return: :class:`CreatePrimaryIPResponse ` + """ + + data: dict[str, Any] = { + "name": name, + "type": type, + "assignee_type": assignee_type, + "auto_delete": auto_delete, + } + if datacenter is not None: + warnings.warn( + "The 'datacenter' argument is deprecated and will be removed after 1 July 2026. " + "Please use the 'location' argument instead. " + "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters", + DeprecationWarning, + stacklevel=2, + ) + data["datacenter"] = datacenter.id_or_name + if location is not None: + data["location"] = location.id_or_name + if assignee_id is not None: + data["assignee_id"] = assignee_id + if labels is not None: + data["labels"] = labels + + response = self._client.request(url=self._base_url, json=data, method="POST") + + action = None + if response.get("action") is not None: + action = BoundAction(self._parent.actions, response["action"]) + + result = CreatePrimaryIPResponse( + primary_ip=BoundPrimaryIP(self, response["primary_ip"]), action=action + ) + return result + + def update( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + auto_delete: bool | None = None, + labels: dict[str, str] | None = None, + name: str | None = None, + ) -> BoundPrimaryIP: + """Updates the name, auto_delete or labels of a Primary IP. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :param auto_delete: bool (optional) + Delete this Primary IP when the resource it is assigned to is deleted + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param name: str (optional) + New name to set + :return: :class:`BoundPrimaryIP ` + """ + data: dict[str, Any] = {} + if auto_delete is not None: + data["auto_delete"] = auto_delete + if labels is not None: + data["labels"] = labels + if name is not None: + data["name"] = name + + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}", + method="PUT", + json=data, + ) + return BoundPrimaryIP(self, response["primary_ip"]) + + def delete(self, primary_ip: PrimaryIP | BoundPrimaryIP) -> bool: + """Deletes a Primary IP. If it is currently assigned to an assignee it will automatically get unassigned. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :return: boolean + """ + self._client.request( + 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 + return True + + def change_protection( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + delete: bool | None = None, + ) -> BoundAction: + """Changes the protection configuration of the Primary IP. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :param delete: boolean + If true, prevents the Primary IP from being deleted + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {} + if delete is not None: + data.update({"delete": delete}) + + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}/actions/change_protection", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def assign( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + assignee_id: int, + assignee_type: str = "server", + ) -> BoundAction: + """Assigns a Primary IP to a assignee_id. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :param assignee_id: int + Assignee the Primary IP shall be assigned to + :param assignee_type: str + Assignee the Primary IP shall be assigned to + :return: :class:`BoundAction ` + """ + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}/actions/assign", + method="POST", + json={"assignee_id": assignee_id, "assignee_type": assignee_type}, + ) + 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. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :return: :class:`BoundAction ` + """ + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}/actions/unassign", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_dns_ptr( + self, + primary_ip: PrimaryIP | BoundPrimaryIP, + ip: str, + dns_ptr: str, + ) -> BoundAction: + """Changes the dns ptr that will appear when getting the dns ptr belonging to this Primary IP. + + :param primary_ip: :class:`BoundPrimaryIP ` or :class:`PrimaryIP ` + :param ip: str + The IP address for which to set the reverse DNS entry + :param dns_ptr: str + Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` + :return: :class:`BoundAction ` + """ + response = self._client.request( + url=f"{self._base_url}/{primary_ip.id}/actions/change_dns_ptr", + method="POST", + json={"ip": ip, "dns_ptr": dns_ptr}, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/primary_ips/domain.py b/hcloud/primary_ips/domain.py new file mode 100644 index 00000000..96749be2 --- /dev/null +++ b/hcloud/primary_ips/domain.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..datacenters import BoundDatacenter + from ..locations import BoundLocation + from ..rdns import DNSPtr + from .client import BoundPrimaryIP + +__all__ = [ + "PrimaryIP", + "PrimaryIPProtection", + "CreatePrimaryIPResponse", +] + + +class PrimaryIP(BaseDomain, DomainIdentityMixin): + """Primary IP Domain + + :param id: int + ID of the Primary IP + :param ip: str + IP address of the Primary IP + :param type: str + Type of Primary IP. Choices: `ipv4`, `ipv6` + :param dns_ptr: List[Dict] + Array of reverse DNS entries + :param datacenter: :class:`Datacenter ` + Datacenter the Primary IP was created in. + + This property is deprecated and will be removed after 1 July 2026. + Please use the ``location`` property instead. + + See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters. + + :param location: :class:`Location ` + Location the Primary IP was created in. + :param blocked: boolean + Whether the IP is blocked + :param protection: dict + Protection configuration for the Primary IP + :param labels: dict + User-defined labels (key-value pairs) + :param created: datetime + Point in time when the Primary IP was created + :param name: str + Name of the Primary IP + :param assignee_id: int + Assignee ID the Primary IP is assigned to + :param assignee_type: str + Assignee Type of entity the Primary IP is assigned to + :param auto_delete: bool + Delete the Primary IP when the Assignee it is assigned to is deleted. + """ + + __properties__ = ( + "id", + "ip", + "type", + "dns_ptr", + "location", + "blocked", + "protection", + "labels", + "created", + "name", + "assignee_id", + "assignee_type", + "auto_delete", + ) + __api_properties__ = ( + *__properties__, + "datacenter", + ) + __slots__ = ( + *__properties__, + "_datacenter", + ) + + def __init__( + self, + id: int | None = None, + type: str | None = None, + ip: str | None = None, + dns_ptr: list[DNSPtr] | None = None, + datacenter: BoundDatacenter | None = None, + location: BoundLocation | None = None, + blocked: bool | None = None, + protection: PrimaryIPProtection | None = None, + labels: dict[str, str] | None = None, + created: str | None = None, + name: str | None = None, + assignee_id: int | None = None, + assignee_type: str | None = None, + auto_delete: bool | None = None, + ): + self.id = id + 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 = self._parse_datetime(created) + self.name = name + self.assignee_id = assignee_id + self.assignee_type = assignee_type + self.auto_delete = auto_delete + + @property + def datacenter(self) -> BoundDatacenter | None: + """ + :meta private: + """ + warnings.warn( + "The 'datacenter' property is deprecated and will be removed after 1 July 2026. " + "Please use the 'location' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters.", + DeprecationWarning, + stacklevel=2, + ) + return self._datacenter + + @datacenter.setter + def datacenter(self, value: BoundDatacenter | None) -> None: + self._datacenter = value + + +class PrimaryIPProtection(TypedDict): + delete: bool + + +class CreatePrimaryIPResponse(BaseDomain): + """Create Primary IP Response Domain + + :param primary_ip: :class:`BoundPrimaryIP ` + The Primary IP which was created + :param action: :class:`BoundAction ` + The Action which shows the progress of the Primary IP Creation + """ + + __api_properties__ = ("primary_ip", "action") + __slots__ = __api_properties__ + + def __init__( + self, + primary_ip: BoundPrimaryIP, + action: BoundAction | None, + ): + self.primary_ip = primary_ip + self.action = action diff --git a/hcloud/py.typed b/hcloud/py.typed new file mode 100644 index 00000000..1242d432 --- /dev/null +++ b/hcloud/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. 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 40a96afc..6840e327 100644 --- a/hcloud/server_types/__init__.py +++ b/hcloud/server_types/__init__.py @@ -1 +1,16 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +from .client import ( + BoundServerType, + ServerTypesClient, + ServerTypesPageResult, +) +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 ab759ae2..98da70af 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -1,28 +1,69 @@ -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin -from hcloud.server_types.domain import ServerType +from __future__ import annotations +from typing import Any, NamedTuple + +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation +from .domain import ServerType, ServerTypeLocation + +__all__ = [ + "BoundServerType", + "ServerTypesPageResult", + "ServerTypesClient", +] + + +class BoundServerType(BoundModelBase[ServerType], ServerType): + _client: ServerTypesClient -class BoundServerType(BoundModelBase): 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 + -class ServerTypesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "server_types" +class ServerTypesClient(ResourceClientBase): + _base_url = "/server_types" - def get_by_id(self, id): - # type: (int) -> server_types.client.BoundServerType + def get_by_id(self, id: int) -> BoundServerType: """Returns a specific Server Type. :param id: int :return: :class:`BoundServerType ` """ - response = self._client.request( - url="/server_types/{server_type_id}".format(server_type_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServerType(self, response["server_type"]) - def get_list(self, name=None, page=None, per_page=None): - # type: (Optional[str], Optional[int], Optional[int]) -> PageResults[List[BoundServerType], Meta] + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ServerTypesPageResult: """Get a list of Server types :param name: str (optional) @@ -33,7 +74,7 @@ def get_list(self, name=None, page=None, per_page=None): Specifies how many results are returned by page :return: (List[:class:`BoundServerType `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if page is not None: @@ -41,31 +82,27 @@ def get_list(self, name=None, page=None, per_page=None): 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"] ] - return self._add_meta_to_result(server_types, response) + return ServerTypesPageResult(server_types, Meta.parse_meta(response)) - def get_all(self, name=None): - # type: (Optional[str]) -> List[BoundServerType] + def get_all(self, name: str | None = None) -> list[BoundServerType]: """Get all Server types :param name: str (optional) Can be used to filter server type by their name. :return: List[:class:`BoundServerType `] """ - return super(ServerTypesClient, self).get_all(name=name) + return self._iter_pages(self.get_list, name=name) - def get_by_name(self, name): - # type: (str) -> BoundServerType + def get_by_name(self, name: str) -> BoundServerType | None: """Get Server type by name :param name: str Used to get Server type by name. :return: :class:`BoundServerType ` """ - return super(ServerTypesClient, self).get_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 8421a0b7..279d928d 100644 --- a/hcloud/server_types/domain.py +++ b/hcloud/server_types/domain.py @@ -1,5 +1,16 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +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): @@ -11,55 +22,184 @@ 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` :param cpu_type: string Type of cpu. Choices: `shared`, `dedicated` + :param architecture: string + Architecture of cpu. Choices: `x86`, `arm` :param deprecated: bool - True if server type is deprecated + True if server type is deprecated. This field is deprecated. Use `deprecation` instead. + :param deprecation: :class:`DeprecationInfo `, None + Describes if, when & how the resources was deprecated. If this field is set to None the resource is not + 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", "prices", "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=None, - name=None, - description=None, - cores=None, - memory=None, - disk=None, - prices=None, - storage_type=None, - cpu_type=None, - deprecated=None, + 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: 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[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 self.prices = prices 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. + """ + + __api_properties__ = ( + "location", + "deprecation", + ) + __slots__ = __api_properties__ + + def __init__( + self, + *, + location: BoundLocation, + deprecation: dict[str, Any] | None, + ): + self.location = location + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) diff --git a/hcloud/servers/__init__.py b/hcloud/servers/__init__.py index 40a96afc..c71c63ef 100644 --- a/hcloud/servers/__init__.py +++ b/hcloud/servers/__init__.py @@ -1 +1,41 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 414527c8..1128f420 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,78 +1,150 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin - -from hcloud.actions.client import BoundAction -from hcloud.core.domain import add_meta_to_result -from hcloud.firewalls.client import BoundFirewall -from hcloud.floating_ips.client import BoundFloatingIP -from hcloud.isos.client import BoundIso -from hcloud.servers.domain import ( - Server, +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 ( + ActionSort, + ActionsPageResult, + ActionStatus, + BoundAction, + ResourceActionsClient, +) +from ..actions.client import ResourceClientBaseActionsMixin +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..datacenters import BoundDatacenter +from ..firewalls import BoundFirewall +from ..floating_ips import BoundFloatingIP +from ..images import BoundImage, CreateImageResponse +from ..isos import BoundIso +from ..locations import BoundLocation, Location +from ..metrics import Metrics +from ..placement_groups import BoundPlacementGroup +from ..primary_ips import BoundPrimaryIP +from ..server_types import BoundServerType +from ..volumes import BoundVolume +from .domain import ( CreateServerResponse, - ResetPasswordResponse, EnableRescueResponse, - RequestConsoleResponse, - PublicNetwork, + GetMetricsResponse, IPv4Address, IPv6Network, + MetricsType, PrivateNet, + PublicNetwork, PublicNetworkFirewall, + RebuildResponse, + RequestConsoleResponse, + ResetPasswordResponse, + Server, ) -from hcloud.volumes.client import BoundVolume -from hcloud.images.domain import CreateImageResponse -from hcloud.images.client import BoundImage -from hcloud.server_types.client import BoundServerType -from hcloud.datacenters.client import BoundDatacenter -from hcloud.networks.client import BoundNetwork # noqa -from hcloud.networks.domain import Network # noqa -from hcloud.placement_groups.client import BoundPlacementGroup +if TYPE_CHECKING: + from .._client import Client + from ..datacenters import Datacenter + from ..firewalls import Firewall + from ..images import Image + from ..isos import Iso + from ..networks import BoundNetwork, Network + from ..placement_groups import PlacementGroup + from ..server_types import ServerType + from ..ssh_keys import BoundSSHKey, SSHKey + from ..volumes import Volume + from .domain import ServerCreatePublicNetwork + + +__all__ = [ + "BoundServer", + "ServersPageResult", + "ServersClient", +] + + +class BoundServer(BoundModelBase[Server], Server): + _client: ServersClient -class BoundServer(BoundModelBase): model = Server - def __init__(self, client, data, complete=True): + # pylint: disable=too-many-locals + def __init__( + self, + client: ServersClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("datacenter") + if raw: + data["datacenter"] = BoundDatacenter(client._parent.datacenters, raw) - datacenter = data.get("datacenter") - if datacenter is not None: - data["datacenter"] = BoundDatacenter(client._client.datacenters, datacenter) + 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") if public_net: - ipv4_address = IPv4Address.from_dict(public_net["ipv4"]) - ipv6_network = IPv6Network.from_dict(public_net["ipv6"]) + ipv4_address = ( + IPv4Address.from_dict(public_net["ipv4"]) + if public_net["ipv4"] is not None + else None + ) + ipv4_primary_ip = ( + BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net["ipv4"]["id"]}, + complete=False, + ) + if public_net["ipv4"] is not None + else None + ) + ipv6_network = ( + IPv6Network.from_dict(public_net["ipv6"]) + if public_net["ipv6"] is not None + else None + ) + ipv6_primary_ip = ( + BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net["ipv6"]["id"]}, + complete=False, + ) + if public_net["ipv6"] is not None + else None + ) 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"], ) @@ -81,16 +153,21 @@ def __init__(self, client, data, complete=True): data["public_net"] = PublicNetwork( ipv4=ipv4_address, ipv6=ipv6_network, + primary_ipv4=ipv4_primary_ip, + primary_ipv6=ipv6_primary_ip, floating_ips=floating_ips, firewalls=firewalls, ) private_nets = data.get("private_net") if private_nets: + # pylint: disable=import-outside-toplevel + from ..networks import BoundNetwork + private_nets = [ PrivateNet( network=BoundNetwork( - client._client.networks, + client._parent.networks, {"id": private_net["network"]}, complete=False, ), @@ -105,42 +182,53 @@ def __init__(self, client, data, complete=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 - super(BoundServer, self).__init__(client, data, complete) + super().__init__(client, data, complete) - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a server. + 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 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=None, sort=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a server. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundServer + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundServer: """Updates a server. You can update a server’s name and a server’s labels. :param name: str (optional) @@ -149,66 +237,85 @@ def update(self, name=None, labels=None): 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, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a Server. + + :param server: The Server to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + return self._client.get_metrics( + self, + type=type, + start=start, + end=end, + step=step, + ) - def delete(self): - # type: () -> BoundAction + def delete(self) -> BoundAction: """Deletes a server. This immediately removes the server from your account, and it is no longer accessible. :return: :class:`BoundAction ` """ return self._client.delete(self) - def power_off(self): - # type: () -> BoundAction + def power_off(self) -> BoundAction: """Cuts power to the server. This forcefully stops it without giving the server operating system time to gracefully stop :return: :class:`BoundAction ` """ return self._client.power_off(self) - def power_on(self): - # type: () -> BoundAction + def power_on(self) -> BoundAction: """Starts a server by turning its power on. :return: :class:`BoundAction ` """ return self._client.power_on(self) - def reboot(self): - # type: () -> BoundAction + def reboot(self) -> BoundAction: """Reboots a server gracefully by sending an ACPI request. :return: :class:`BoundAction ` """ return self._client.reboot(self) - def reset(self): - # type: () -> BoundAction + def reset(self) -> BoundAction: """Cuts power to a server and starts it again. :return: :class:`BoundAction ` """ return self._client.reset(self) - def shutdown(self): - # type: () -> BoundAction + def shutdown(self) -> BoundAction: """Shuts down a server gracefully by sending an ACPI shutdown request. :return: :class:`BoundAction ` """ return self._client.shutdown(self) - def reset_password(self): - # type: () -> ResetPasswordResponse + def reset_password(self) -> ResetPasswordResponse: """Resets the root password. Only works for Linux systems that are running the qemu guest agent. :return: :class:`ResetPasswordResponse ` """ return self._client.reset_password(self) - def enable_rescue(self, type=None, ssh_keys=None): - # type: (str, Optional[List[str]]) -> EnableRescueResponse + def enable_rescue( + self, + type: str | None = None, + ssh_keys: list[str] | None = None, + ) -> EnableRescueResponse: """Enable the Hetzner Rescue System for this server. :param type: str @@ -220,16 +327,19 @@ def enable_rescue(self, type=None, ssh_keys=None): """ return self._client.enable_rescue(self, type=type, ssh_keys=ssh_keys) - def disable_rescue(self): - # type: () -> BoundAction + def disable_rescue(self) -> BoundAction: """Disables the Hetzner Rescue System for a server. :return: :class:`BoundAction ` """ return self._client.disable_rescue(self) - def create_image(self, description=None, type=None, labels=None): - # type: (str, str, Optional[Dict[str, str]]) -> CreateImageResponse + def create_image( + self, + description: str | None = None, + type: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateImageResponse: """Creates an image (snapshot) from a server by copying the contents of its disks. :param description: str (optional) @@ -241,19 +351,29 @@ def create_image(self, description=None, type=None, labels=None): 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): - # type: (Image) -> BoundAction + def rebuild( + self, + image: Image | BoundImage, + user_data: str | None = None, + # pylint: disable=unused-argument + **kwargs: Any, + ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. - :param image: :class:`BoundImage ` or :class:`Image ` - :return: :class:`BoundAction ` + :param image: Image to use for the rebuilt server + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ - return self._client.rebuild(self, image) + return self._client.rebuild(self, image=image, user_data=user_data) - def change_type(self, server_type, upgrade_disk): - # type: (BoundServerType, bool) -> BoundAction + def change_type( + self, + server_type: ServerType | BoundServerType, + upgrade_disk: bool, + ) -> BoundAction: """Changes the type (Cores, RAM and disk sizes) of a server. :param server_type: :class:`BoundServerType ` or :class:`ServerType ` @@ -262,43 +382,40 @@ def change_type(self, server_type, upgrade_disk): 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): - # type: () -> BoundAction + 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%. :return: :class:`BoundAction ` """ return self._client.enable_backup(self) - def disable_backup(self): - # type: () -> BoundAction + def disable_backup(self) -> BoundAction: """Disables the automatic backup option and deletes all existing Backups for a Server. :return: :class:`BoundAction ` """ return self._client.disable_backup(self) - def attach_iso(self, iso): - # type: (Iso) -> BoundAction + def attach_iso(self, iso: Iso | BoundIso) -> BoundAction: """Attaches an ISO to a server. :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): - # type: () -> BoundAction + def detach_iso(self) -> BoundAction: """Detaches an ISO from a server. :return: :class:`BoundAction ` """ return self._client.detach_iso(self) - def change_dns_ptr(self, ip, dns_ptr): - # type: (str, Optional[str]) -> BoundAction + def change_dns_ptr(self, ip: str, dns_ptr: str | None) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to the primary IPs (ipv4 and ipv6) of this server. :param ip: str @@ -307,10 +424,13 @@ def change_dns_ptr(self, ip, dns_ptr): 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=None, rebuild=None): - # type: (Optional[bool], Optional[bool]) -> BoundAction + def change_protection( + self, + delete: bool | None = None, + rebuild: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of the server. :param server: :class:`BoundServer ` or :class:`Server ` @@ -320,18 +440,22 @@ def change_protection(self, delete=None, rebuild=None): 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): - # type: () -> RequestConsoleResponse + def request_console(self) -> RequestConsoleResponse: """Requests credentials for remote access via vnc over websocket to keyboard, monitor, and mouse for a server. :return: :class:`RequestConsoleResponse ` """ return self._client.request_console(self) - def attach_to_network(self, network, ip=None, alias_ips=None): - # type: (Union[Network,BoundNetwork],Optional[str], Optional[List[str]]) -> BoundAction + def attach_to_network( + self, + 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 :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -339,21 +463,31 @@ def attach_to_network(self, network, ip=None, alias_ips=None): 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): - # type: ( Union[Network,BoundNetwork]) -> BoundAction + def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: """Detaches a server from a network. :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, network, alias_ips): - # type: (Union[Network,BoundNetwork], List[str]) -> BoundAction + def change_alias_ips( + self, + network: Network | BoundNetwork, + alias_ips: list[str], + ) -> BoundAction: """Changes the alias IPs of an already attached network. :param network: :class:`BoundNetwork ` or :class:`Network ` @@ -361,19 +495,22 @@ def change_alias_ips(self, network, 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, placement_group): - # type: (Union[PlacementGroup,BoundPlacementGroup]) -> BoundAction + def add_to_placement_group( + self, + placement_group: PlacementGroup | BoundPlacementGroup, + ) -> BoundAction: """Adds a server to a 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): - # type: () -> BoundAction + def remove_from_placement_group(self) -> BoundAction: """Removes a server from a placement group. :return: :class:`BoundAction ` @@ -381,30 +518,44 @@ def remove_from_placement_group(self): return self._client.remove_from_placement_group(self) -class ServersClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "servers" +class ServersPageResult(NamedTuple): + servers: list[BoundServer] + meta: Meta - def get_by_id(self, id): - # type: (int) -> BoundServer + +class ServersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/servers" + + actions: ResourceActionsClient + """Servers scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_by_id(self, id: int) -> BoundServer: """Get a specific server :param id: int :return: :class:`BoundServer ` """ - response = self._client.request( - url="/servers/{server_id}".format(server_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundServer(self, response["server"]) def get_list( self, - name=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - status=None, # type: Optional[List[str]] - ): - # type: (...) -> PageResults[List[BoundServer], Meta] + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + status: list[str] | None = None, + ) -> ServersPageResult: """Get a list of servers from this account :param name: str (optional) @@ -419,7 +570,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundServer `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if label_selector is not None: @@ -431,15 +582,19 @@ 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"] ] - return self._add_meta_to_result(ass_servers, response) + return ServersPageResult(ass_servers, Meta.parse_meta(response)) - def get_all(self, name=None, label_selector=None, status=None): - # type: (Optional[str], Optional[str], Optional[List[str]]) -> List[BoundServer] + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + status: list[str] | None = None, + ) -> list[BoundServer]: """Get all servers from this account :param name: str (optional) @@ -450,38 +605,41 @@ def get_all(self, name=None, label_selector=None, status=None): Can be used to filter servers by their status. The response will only contain servers matching the status. :return: List[:class:`BoundServer `] """ - return super(ServersClient, self).get_all( - name=name, label_selector=label_selector, status=status + return self._iter_pages( + self.get_list, + name=name, + label_selector=label_selector, + status=status, ) - def get_by_name(self, name): - # type: (str) -> BoundServer + def get_by_name(self, name: str) -> BoundServer | None: """Get server by name :param name: str Used to get server by name. :return: :class:`BoundServer ` """ - return super(ServersClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) + # pylint: disable=too-many-branches,too-many-locals def create( self, - name, # type: str - server_type, # type: ServerType - image, # type: Image - ssh_keys=None, # type: Optional[List[SSHKey]] - volumes=None, # type: Optional[List[Volume]] - firewalls=None, # type: Optional[List[Firewall]] - networks=None, # type: Optional[List[Network]] - user_data=None, # type: Optional[str] - labels=None, # type: Optional[Dict[str, str]] - location=None, # type: Optional[Location] - datacenter=None, # type: Optional[Datacenter] - start_after_create=True, # type: Optional[bool] - automount=None, # type: Optional[bool] - placement_group=None, # type: Optional[PlacementGroup] - ): - # type: (...) -> CreateServerResponse + name: str, + server_type: ServerType | BoundServerType, + image: Image, + ssh_keys: list[SSHKey | BoundSSHKey] | None = None, + volumes: list[Volume | BoundVolume] | None = None, + firewalls: list[Firewall | BoundFirewall] | None = None, + networks: list[Network | BoundNetwork] | None = None, + 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, + public_net: ServerCreatePublicNetwork | None = None, + ) -> CreateServerResponse: """Creates a new server. Returns preliminary information about the server as well as an action that covers progress of creation. :param name: str @@ -508,9 +666,11 @@ def create( Auto mount volumes after attach. :param placement_group: :class:`BoundPlacementGroup ` or :class:`Location ` Placement Group where server should be added during creation + :param public_net: :class:`ServerCreatePublicNetwork ` + Options to configure the public network of a server on creation :return: :class:`CreateServerResponse ` """ - data = { + data: dict[str, Any] = { "name": name, "server_type": server_type.id_or_name, "start_after_create": start_after_create, @@ -520,6 +680,13 @@ def create( if location is not None: data["location"] = location.id_or_name if datacenter is not None: + warnings.warn( + "The 'datacenter' argument is deprecated and will be removed after 1 July 2026. " + "Please use the 'location' argument instead. " + "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters", + DeprecationWarning, + stacklevel=2, + ) data["datacenter"] = datacenter.id_or_name if ssh_keys is not None: data["ssh_keys"] = [ssh_key.id_or_name for ssh_key in ssh_keys] @@ -538,13 +705,24 @@ def create( if placement_group is not None: data["placement_group"] = placement_group.id - response = self._client.request(url="/servers", method="POST", json=data) + if public_net is not None: + data_public_net: dict[str, Any] = { + "enable_ipv4": public_net.enable_ipv4, + "enable_ipv6": public_net.enable_ipv6, + } + if public_net.ipv4 is not None: + data_public_net["ipv4"] = public_net.ipv4.id + if public_net.ipv6 is not None: + data_public_net["ipv6"] = public_net.ipv6.id + data["public_net"] = data_public_net + + 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"], @@ -552,58 +730,56 @@ def create( return result def get_actions_list( - self, server, status=None, sort=None, page=None, per_page=None - ): - # type: (Server, Optional[List[str]], Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] - """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 `) + self, + server: Server | BoundServer, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: """ - params = {} - 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="/servers/{server_id}/actions".format(server_id=server.id), - 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 add_meta_to_result(actions, response, "actions") - def get_actions(self, server, status=None, sort=None): - # type: (Server, Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a server. + def get_actions( + self, + server: Server | BoundServer, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(ServersClient, self).get_actions(server, status=status, sort=sort) + return self._iter_pages( + self.get_actions_list, + server, + status=status, + sort=sort, + ) - def update(self, server, name=None, labels=None): - # type:(Server, Optional[str], Optional[Dict[str, str]]) -> BoundServer + def update( + self, + server: Server | BoundServer, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundServer: """Updates a server. You can update a server’s name and a server’s labels. :param server: :class:`BoundServer ` or :class:`Server ` @@ -613,115 +789,150 @@ def update(self, server, name=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundServer ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data.update({"name": name}) if labels is not None: data.update({"labels": labels}) response = self._client.request( - url="/servers/{server_id}".format(server_id=server.id), + url=f"{self._base_url}/{server.id}", method="PUT", json=data, ) return BoundServer(self, response["server"]) - def delete(self, server): - # type: (Server) -> BoundAction + def get_metrics( + self, + server: Server | BoundServer, + type: MetricsType | list[MetricsType], + start: datetime | str, + end: datetime | str, + step: float | None = None, + ) -> GetMetricsResponse: + """Get Metrics for a Server. + + :param server: The Server to get the metrics for. + :param type: Type of metrics to get. + :param start: Start of period to get Metrics for (in ISO-8601 format). + :param end: End of period to get Metrics for (in ISO-8601 format). + :param step: Resolution of results in seconds. + """ + if not isinstance(type, list): + type = [type] + if isinstance(start, str): + start = isoparse(start) + if isinstance(end, str): + end = isoparse(end) + + params: dict[str, Any] = { + "type": ",".join(type), + "start": start.isoformat(), + "end": end.isoformat(), + } + if step is not None: + params["step"] = step + + response = self._client.request( + url=f"{self._base_url}/{server.id}/metrics", + method="GET", + params=params, + ) + return GetMetricsResponse( + metrics=Metrics(**response["metrics"]), + ) + + def delete(self, server: Server | BoundServer) -> BoundAction: """Deletes a server. This immediately removes the server from your account, and it is no longer accessible. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}".format(server_id=server.id), method="DELETE" + url=f"{self._base_url}/{server.id}", method="DELETE" ) - return BoundAction(self._client.actions, response["action"]) + return BoundAction(self._parent.actions, response["action"]) - def power_off(self, server): - # type: (Server) -> 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 :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/poweroff".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> actions.domain.Action + def power_on(self, server: Server | BoundServer) -> BoundAction: """Starts a server by turning its power on. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/poweron".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> actions.domain.Action + def reboot(self, server: Server | BoundServer) -> BoundAction: """Reboots a server gracefully by sending an ACPI request. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/reboot".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> actions.domainAction + def reset(self, server: Server | BoundServer) -> BoundAction: """Cuts power to a server and starts it again. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/reset".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> actions.domainAction + def shutdown(self, server: Server | BoundServer) -> BoundAction: """Shuts down a server gracefully by sending an ACPI shutdown request. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/shutdown".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> ResetPasswordResponse + def reset_password(self, server: Server | BoundServer) -> ResetPasswordResponse: """Resets the root password. Only works for Linux systems that are running the qemu guest agent. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`ResetPasswordResponse ` """ response = self._client.request( - url="/servers/{server_id}/actions/reset_password".format( - server_id=server.id - ), + 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"], ) - def change_type(self, server, server_type, upgrade_disk): - # type: (servers.domain.Server, BoundServerType, bool) -> actions.domainAction + def change_type( + self, + server: Server | BoundServer, + server_type: ServerType | BoundServerType, + upgrade_disk: bool, + ) -> BoundAction: """Changes the type (Cores, RAM and disk sizes) of a server. :param server: :class:`BoundServer ` or :class:`Server ` @@ -731,16 +942,23 @@ def change_type(self, server, server_type, upgrade_disk): If false, do not upgrade the disk. This allows downgrading the server type later. :return: :class:`BoundAction ` """ - data = {"server_type": server_type.id_or_name, "upgrade_disk": upgrade_disk} + data: dict[str, Any] = { + "server_type": server_type.id_or_name, + "upgrade_disk": upgrade_disk, + } response = self._client.request( - url="/servers/{server_id}/actions/change_type".format(server_id=server.id), + 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, server, type=None, ssh_keys=None): - # type: (servers.domain.Server, str, Optional[List[str]]) -> EnableRescueResponse + def enable_rescue( + self, + server: Server | BoundServer, + type: str | None = None, + ssh_keys: list[str] | None = None, + ) -> EnableRescueResponse: """Enable the Hetzner Rescue System for this server. :param server: :class:`BoundServer ` or :class:`Server ` @@ -751,39 +969,39 @@ def enable_rescue(self, server, type=None, ssh_keys=None): Array of SSH key IDs which should be injected into the rescue system. Only available for types: linux64 and linux32. :return: :class:`EnableRescueResponse ` """ - data = {"type": type} + data: dict[str, Any] = {"type": type} if ssh_keys is not None: data.update({"ssh_keys": ssh_keys}) response = self._client.request( - url="/servers/{server_id}/actions/enable_rescue".format( - server_id=server.id - ), + 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"], ) - def disable_rescue(self, server): - # type: (servers.domain.Server) -> actions.domainAction + def disable_rescue(self, server: Server | BoundServer) -> BoundAction: """Disables the Hetzner Rescue System for a server. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/disable_rescue".format( - server_id=server.id - ), + 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, server, description=None, type=None, labels=None): - # type: (servers.domain.Server, str, str, Optional[Dict[str, str]]) -> CreateImageResponse + def create_image( + self, + server: Server | BoundServer, + description: str | None = None, + type: str | None = None, + labels: dict[str, str] | None = None, + ) -> CreateImageResponse: """Creates an image (snapshot) from a server by copying the contents of its disks. :param server: :class:`BoundServer ` or :class:`Server ` @@ -796,7 +1014,7 @@ def create_image(self, server, description=None, type=None, labels=None): User-defined labels (key-value pairs) :return: :class:`CreateImageResponse ` """ - data = {} + data: dict[str, Any] = {} if description is not None: data.update({"description": description}) @@ -807,92 +1025,105 @@ def create_image(self, server, description=None, type=None, labels=None): data.update({"labels": labels}) response = self._client.request( - url="/servers/{server_id}/actions/create_image".format(server_id=server.id), + 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, image): - # type: (servers.domain.Server, Image) -> actions.domainAction + def rebuild( + self, + server: Server | BoundServer, + image: Image | BoundImage, + user_data: str | None = None, + # pylint: disable=unused-argument + **kwargs: Any, + ) -> RebuildResponse: """Rebuilds a server overwriting its disk with the content of an image, thereby destroying all data on the target server. - :param server: :class:`BoundServer ` or :class:`Server ` - :param image: :class:`BoundImage ` or :class:`Image ` - :return: :class:`BoundAction ` + :param server: Server to rebuild + :param image: Image to use for the rebuilt server + :param user_data: Cloud-Init user data to use during Server rebuild (optional) """ - data = {"image": image.id_or_name} + 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="/servers/{server_id}/actions/rebuild".format(server_id=server.id), + url=f"{self._base_url}/{server.id}/actions/rebuild", method="POST", json=data, ) - return BoundAction(self._client.actions, response["action"]) - def enable_backup(self, server): - # type: (servers.domain.Server) -> actions.domainAction + return RebuildResponse( + action=BoundAction(self._parent.actions, response["action"]), + root_password=response.get("root_password"), + ) + + 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%. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/enable_backup".format( - server_id=server.id - ), + 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): - # type: (servers.domain.Server) -> actions.domainAction + def disable_backup(self, server: Server | BoundServer) -> BoundAction: """Disables the automatic backup option and deletes all existing Backups for a Server. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/disable_backup".format( - server_id=server.id - ), + 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, server, iso): - # type: (servers.domain.Server, Iso) -> actions.domainAction + def attach_iso( + self, + server: Server | BoundServer, + iso: Iso | BoundIso, + ) -> BoundAction: """Attaches an ISO to a server. :param server: :class:`BoundServer ` or :class:`Server ` :param iso: :class:`BoundIso ` or :class:`Server ` :return: :class:`BoundAction ` """ - data = {"iso": iso.id_or_name} + data: dict[str, Any] = {"iso": iso.id_or_name} response = self._client.request( - url="/servers/{server_id}/actions/attach_iso".format(server_id=server.id), + 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): - # type: (servers.domain.Server) -> actions.domainAction + def detach_iso(self, server: Server | BoundServer) -> BoundAction: """Detaches an ISO from a server. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/detach_iso".format(server_id=server.id), + 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, server, ip, dns_ptr): - # type: (servers.domain.Server, str, str) -> actions.domainAction + def change_dns_ptr( + self, + server: Server | BoundServer, + ip: str, + dns_ptr: str | None, + ) -> BoundAction: """Changes the hostname that will appear when getting the hostname belonging to the primary IPs (ipv4 and ipv6) of this server. :param server: :class:`BoundServer ` or :class:`Server ` @@ -902,18 +1133,20 @@ def change_dns_ptr(self, server, ip, dns_ptr): Hostname to set as a reverse DNS PTR entry, will reset to original default value if `None` :return: :class:`BoundAction ` """ - data = {"ip": ip, "dns_ptr": dns_ptr} + data: dict[str, Any] = {"ip": ip, "dns_ptr": dns_ptr} response = self._client.request( - url="/servers/{server_id}/actions/change_dns_ptr".format( - server_id=server.id - ), + 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, server, delete=None, rebuild=None): - # type: (servers.domain.Server, Optional[bool], Optional[bool]) -> actions.domainAction + def change_protection( + self, + server: Server | BoundServer, + delete: bool | None = None, + rebuild: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of the server. :param server: :class:`BoundServer ` or :class:`Server ` @@ -923,42 +1156,43 @@ def change_protection(self, server, delete=None, rebuild=None): If true, prevents the server from being rebuilt (currently delete and rebuild attribute needs to have the same value) :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) if rebuild is not None: data.update({"rebuild": rebuild}) response = self._client.request( - url="/servers/{server_id}/actions/change_protection".format( - server_id=server.id - ), + 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): - # type: (servers.domain.Server) -> RequestConsoleResponse + 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. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`RequestConsoleResponse ` """ response = self._client.request( - url="/servers/{server_id}/actions/request_console".format( - server_id=server.id - ), + 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"], ) - def attach_to_network(self, server, network, ip=None, alias_ips=None): - # type: (Union[Server,BoundServer], Union[Network,BoundNetwork],Optional[str], Optional[List[str]]) -> BoundAction + def attach_to_network( + self, + server: Server | BoundServer, + 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 :param server: :class:`BoundServer ` or :class:`Server ` @@ -967,46 +1201,50 @@ def attach_to_network(self, server, network, ip=None, alias_ips=None): 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 = { - "network": network.id, - } + data: dict[str, Any] = {"network": network.id} if ip is not None: 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="/servers/{server_id}/actions/attach_to_network".format( - server_id=server.id - ), + 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, server, network): - # type: (Union[Server,BoundServer], Union[Network,BoundNetwork]) -> BoundAction + def detach_from_network( + self, + server: Server | BoundServer, + network: Network | BoundNetwork, + ) -> BoundAction: """Detaches a server from a network. :param server: :class:`BoundServer ` or :class:`Server ` :param network: :class:`BoundNetwork ` or :class:`Network ` :return: :class:`BoundAction ` """ - data = { - "network": network.id, - } + data: dict[str, Any] = {"network": network.id} response = self._client.request( - url="/servers/{server_id}/actions/detach_from_network".format( - server_id=server.id - ), + 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, server, network, alias_ips): - # type: (Union[Server,BoundServer], Union[Network,BoundNetwork], List[str]) -> BoundAction + def change_alias_ips( + self, + server: Server | BoundServer, + network: Network | BoundNetwork, + alias_ips: list[str], + ) -> BoundAction: """Changes the alias IPs of an already attached network. :param server: :class:`BoundServer ` or :class:`Server ` @@ -1015,47 +1253,41 @@ def change_alias_ips(self, server, network, alias_ips): New alias IPs to set for this server. :return: :class:`BoundAction ` """ - data = {"network": network.id, "alias_ips": alias_ips} + data: dict[str, Any] = {"network": network.id, "alias_ips": alias_ips} response = self._client.request( - url="/servers/{server_id}/actions/change_alias_ips".format( - server_id=server.id - ), + 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, server, placement_group): - # type: (Union[Server,BoundServer], Union[PlacementGroup,BoundPlacementGroup]) -> BoundAction + def add_to_placement_group( + self, + server: Server | BoundServer, + placement_group: PlacementGroup | BoundPlacementGroup, + ) -> BoundAction: """Adds a server to a placement group. :param server: :class:`BoundServer ` or :class:`Server ` :param placement_group: :class:`BoundPlacementGroup ` or :class:`Network ` :return: :class:`BoundAction ` """ - data = { - "placement_group": str(placement_group.id), - } + data: dict[str, Any] = {"placement_group": placement_group.id} response = self._client.request( - url="/servers/{server_id}/actions/add_to_placement_group".format( - server_id=server.id - ), + 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): - # type: (Union[Server,BoundServer]) -> BoundAction + def remove_from_placement_group(self, server: Server | BoundServer) -> BoundAction: """Removes a server from a placement group. :param server: :class:`BoundServer ` or :class:`Server ` :return: :class:`BoundAction ` """ response = self._client.request( - url="/servers/{server_id}/actions/remove_from_placement_group".format( - server_id=server.id - ), + 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 1ba7fb43..ec019e4e 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,10 +1,48 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse - -from hcloud.core.domain import BaseDomain - - -class Server(BaseDomain): +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Literal, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..datacenters import BoundDatacenter + from ..firewalls import BoundFirewall + from ..floating_ips import BoundFloatingIP + from ..images import BoundImage + from ..isos import BoundIso + from ..locations import BoundLocation + from ..metrics import Metrics + 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 :param id: int @@ -19,6 +57,12 @@ class Server(BaseDomain): Public network information. :param server_type: :class:`BoundServerType ` :param datacenter: :class:`BoundDatacenter ` + + This property is deprecated and will be removed after 1 July 2026. + Please use the ``location`` property instead. + + See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters. + :param location: :class:`BoundLocation ` :param image: :class:`BoundImage `, None :param iso: :class:`BoundIso `, None :param rescue_enabled: bool @@ -41,7 +85,7 @@ class Server(BaseDomain): User-defined labels (key-value pairs) :param volumes: List[:class:`BoundVolume `] Volumes assigned to this server. - :param private_net: List[:class:`PrivateNet `] Private networks information. """ @@ -63,13 +107,14 @@ class Server(BaseDomain): """Server Status rebuilding""" STATUS_UNKNOWN = "unknown" """Server Status unknown""" - __slots__ = ( + + __properties__ = ( "id", "name", "status", "public_net", "server_type", - "datacenter", + "location", "image", "iso", "rescue_enabled", @@ -86,38 +131,49 @@ class Server(BaseDomain): "primary_disk_size", "placement_group", ) + __api_properties__ = ( + *__properties__, + "datacenter", + ) + __slots__ = ( + *__properties__, + "_datacenter", + ) + # pylint: disable=too-many-locals def __init__( self, - id, - name=None, - status=None, - created=None, - public_net=None, - server_type=None, - datacenter=None, - image=None, - iso=None, - rescue_enabled=None, - locked=None, - backup_window=None, - outgoing_traffic=None, - ingoing_traffic=None, - included_traffic=None, - protection=None, - labels=None, - volumes=None, - private_net=None, - primary_disk_size=None, - placement_group=None, + id: int, + name: str | None = None, + status: str | None = None, + 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, + locked: bool | None = None, + backup_window: str | None = None, + outgoing_traffic: int | None = None, + ingoing_traffic: int | None = None, + included_traffic: int | None = None, + protection: ServerProtection | None = None, + labels: dict[str, str] | None = None, + volumes: list[BoundVolume] | None = None, + private_net: list[PrivateNet] | None = None, + primary_disk_size: int | None = None, + placement_group: BoundPlacementGroup | None = None, ): 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 @@ -133,6 +189,39 @@ 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 + + @property + def datacenter(self) -> BoundDatacenter | None: + """ + :meta private: + """ + warnings.warn( + "The 'datacenter' property is deprecated and will be removed after 1 July 2026. " + "Please use the 'location' property instead. " + "See https://docs.hetzner.cloud/changelog#2025-12-16-phasing-out-datacenters.", + DeprecationWarning, + stacklevel=2, + ) + return self._datacenter + + @datacenter.setter + def datacenter(self, value: BoundDatacenter | None) -> None: + self._datacenter = value + + +class ServerProtection(TypedDict): + rebuild: bool + delete: bool + class CreateServerResponse(BaseDomain): """Create Server Response Domain @@ -147,14 +236,15 @@ 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, - server, # type: BoundServer - action, # type: BoundAction - next_actions, # type: List[Action] - root_password, # type: str + server: BoundServer, + action: BoundAction, + next_actions: list[BoundAction], + root_password: str | None, ): self.server = server self.action = action @@ -171,12 +261,13 @@ class ResetPasswordResponse(BaseDomain): The root password of the server """ - __slots__ = ("action", "root_password") + __api_properties__ = ("action", "root_password") + __slots__ = __api_properties__ def __init__( self, - action, # type: BoundAction - root_password, # type: str + action: BoundAction, + root_password: str, ): self.action = action self.root_password = root_password @@ -191,12 +282,13 @@ 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, - action, # type: BoundAction - root_password, # type: str + action: BoundAction, + root_password: str, ): self.action = action self.root_password = root_password @@ -213,41 +305,75 @@ 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, - action, # type: BoundAction - wss_url, # type: str - password, # type: str + action: BoundAction, + wss_url: str, + password: str, ): self.action = action self.wss_url = wss_url self.password = password +class RebuildResponse(BaseDomain): + """Rebuild Response Domain + + :param action: Shows the progress of the server rebuild action + :param root_password: The root password of the server when not using SSH keys + """ + + __api_properties__ = ("action", "root_password") + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + root_password: str | None, + ): + self.action = action + self.root_password = root_password + + class PublicNetwork(BaseDomain): """Public Network Domain :param ipv4: :class:`IPv4Address ` :param ipv6: :class:`IPv6Network ` :param floating_ips: List[:class:`BoundFloatingIP `] + :param primary_ipv4: :class:`BoundPrimaryIP ` + :param primary_ipv6: :class:`BoundPrimaryIP ` :param firewalls: List[:class:`PublicNetworkFirewall `] """ - __slots__ = ("ipv4", "ipv6", "floating_ips", "firewalls") + __api_properties__ = ( + "ipv4", + "ipv6", + "floating_ips", + "firewalls", + "primary_ipv4", + "primary_ipv6", + ) + __slots__ = __api_properties__ def __init__( self, - ipv4, # type: IPv4Address - ipv6, # type: IPv6Network - floating_ips, # type: List[BoundFloatingIP] - firewalls=None, # type: List[PublicNetworkFirewall] + ipv4: IPv4Address | None, + ipv6: IPv6Network | None, + floating_ips: list[BoundFloatingIP], + primary_ipv4: BoundPrimaryIP | None, + primary_ipv6: BoundPrimaryIP | None, + firewalls: list[PublicNetworkFirewall] | None = None, ): self.ipv4 = ipv4 self.ipv6 = ipv6 self.floating_ips = floating_ips self.firewalls = firewalls + self.primary_ipv4 = primary_ipv4 + self.primary_ipv6 = primary_ipv6 class PublicNetworkFirewall(BaseDomain): @@ -257,7 +383,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""" @@ -266,8 +393,8 @@ class PublicNetworkFirewall(BaseDomain): def __init__( self, - firewall, # type: BoundFirewall - status, # type: str + firewall: BoundFirewall, + status: str, ): self.firewall = firewall self.status = status @@ -284,13 +411,14 @@ 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, - ip, # type: str - blocked, # type: bool - dns_ptr, # type: str + ip: str, + blocked: bool, + dns_ptr: str, ): self.ip = ip self.blocked = blocked @@ -312,13 +440,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, # type: str - blocked, # type: bool - dns_ptr, # type: list + ip: str, + blocked: bool, + dns_ptr: list[DNSPtr], ): self.ip = ip self.blocked = blocked @@ -341,16 +470,65 @@ 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, - network, # type: BoundNetwork - ip, # type: str - alias_ips, # type: List[str] - mac_address, # type: str + network: BoundNetwork, + ip: str, + alias_ips: list[str], + mac_address: str, ): self.network = network self.ip = ip self.alias_ips = alias_ips self.mac_address = mac_address + + +class ServerCreatePublicNetwork(BaseDomain): + """Server Create Public Network Domain + + :param ipv4: Optional[:class:`PrimaryIP `] + :param ipv6: Optional[:class:`PrimaryIP `] + :param enable_ipv4: bool + :param enable_ipv6: bool + """ + + __api_properties__ = ("ipv4", "ipv6", "enable_ipv4", "enable_ipv6") + __slots__ = __api_properties__ + + def __init__( + self, + ipv4: PrimaryIP | None = None, + ipv6: PrimaryIP | None = None, + enable_ipv4: bool = True, + enable_ipv6: bool = True, + ): + self.ipv4 = ipv4 + self.ipv6 = ipv6 + self.enable_ipv4 = enable_ipv4 + self.enable_ipv6 = enable_ipv6 + + +MetricsType = Literal[ + "cpu", + "disk", + "network", +] + + +class GetMetricsResponse(BaseDomain): + """Get a Server Metrics Response Domain + + :param metrics: The Server metrics + """ + + __api_properties__ = ("metrics",) + __slots__ = __api_properties__ + + def __init__( + self, + metrics: Metrics, + ): + self.metrics = metrics diff --git a/hcloud/ssh_keys/__init__.py b/hcloud/ssh_keys/__init__.py index 40a96afc..6aefb42e 100644 --- a/hcloud/ssh_keys/__init__.py +++ b/hcloud/ssh_keys/__init__.py @@ -1 +1,11 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 d0e7c02d..d88800a1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -1,56 +1,69 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.ssh_keys.domain import SSHKey +from typing import Any, NamedTuple +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import SSHKey + +__all__ = [ + "BoundSSHKey", + "SSHKeysPageResult", + "SSHKeysClient", +] + + +class BoundSSHKey(BoundModelBase[SSHKey], SSHKey): + _client: SSHKeysClient -class BoundSSHKey(BoundModelBase): model = SSHKey - def update(self, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundSSHKey + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundSSHKey: """Updates an SSH key. You can update an SSH key name and an SSH key labels. :param description: str (optional) New Description to set :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) - :return: :class:`BoundSSHKey + :return: :class:`BoundSSHKey ` """ - return self._client.update(self, name, labels) + return self._client.update(self, name=name, labels=labels) - def delete(self): - # type: () -> bool + def delete(self) -> bool: """Deletes an SSH key. It cannot be used anymore. :return: boolean """ return self._client.delete(self) -class SSHKeysClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "ssh_keys" +class SSHKeysPageResult(NamedTuple): + ssh_keys: list[BoundSSHKey] + meta: Meta + + +class SSHKeysClient(ResourceClientBase): + _base_url = "/ssh_keys" - def get_by_id(self, id): - # type: (int) -> BoundSSHKey + def get_by_id(self, id: int) -> BoundSSHKey: """Get a specific SSH Key by its ID :param id: int :return: :class:`BoundSSHKey ` """ - response = self._client.request( - url="/ssh_keys/{ssh_key_id}".format(ssh_key_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundSSHKey(self, response["ssh_key"]) def get_list( self, - name=None, # type: Optional[str] - fingerprint=None, # type: Optional[str] - label_selector=None, # type: Optional[str] - page=None, # type: Optional[int] - per_page=None, # type: Optional[int] - ): - # type: (...) -> PageResults[List[BoundSSHKey], Meta] + name: str | None = None, + fingerprint: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> SSHKeysPageResult: """Get a list of SSH keys from the account :param name: str (optional) @@ -65,7 +78,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundSSHKey `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if fingerprint is not None: @@ -77,15 +90,19 @@ 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) - ass_ssh_keys = [ + ssh_keys = [ BoundSSHKey(self, server_data) for server_data in response["ssh_keys"] ] - return self._add_meta_to_result(ass_ssh_keys, response) + return SSHKeysPageResult(ssh_keys, Meta.parse_meta(response)) - def get_all(self, name=None, fingerprint=None, label_selector=None): - # type: (Optional[str], Optional[str], Optional[str]) -> List[BoundSSHKey] + def get_all( + self, + name: str | None = None, + fingerprint: str | None = None, + label_selector: str | None = None, + ) -> list[BoundSSHKey]: """Get all SSH keys from the account :param name: str (optional) @@ -96,34 +113,37 @@ def get_all(self, name=None, fingerprint=None, label_selector=None): Can be used to filter SSH keys by labels. The response will only contain SSH keys matching the label selector. :return: List[:class:`BoundSSHKey `] """ - return super(SSHKeysClient, self).get_all( - name=name, fingerprint=fingerprint, label_selector=label_selector + return self._iter_pages( + self.get_list, + name=name, + fingerprint=fingerprint, + label_selector=label_selector, ) - def get_by_name(self, name): - # type: (str) -> SSHKeysClient + def get_by_name(self, name: str) -> BoundSSHKey | None: """Get ssh key by name :param name: str Used to get ssh key by name. :return: :class:`BoundSSHKey ` """ - return super(SSHKeysClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) - def get_by_fingerprint(self, fingerprint): - # type: (str) -> BoundSSHKey + def get_by_fingerprint(self, fingerprint: str) -> BoundSSHKey | None: """Get ssh key by fingerprint :param fingerprint: str Used to get ssh key by fingerprint. :return: :class:`BoundSSHKey ` """ - response = self.get_list(fingerprint=fingerprint) - sshkeys = response.ssh_keys - return sshkeys[0] if sshkeys else None + return self._get_first_by(self.get_list, fingerprint=fingerprint) - def create(self, name, public_key, labels=None): - # type: (str, str, Optional[Dict[str, str]]) -> BoundSSHKey + def create( + self, + name: str, + public_key: str, + labels: dict[str, str] | None = None, + ) -> BoundSSHKey: """Creates a new SSH key with the given name and public_key. :param name: str @@ -133,14 +153,18 @@ def create(self, name, public_key, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundSSHKey ` """ - data = {"name": name, "public_key": public_key} + 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(self, ssh_key, name=None, labels=None): - # type: (SSHKey, Optional[str], Optional[Dict[str, str]]) -> BoundSSHKey + def update( + self, + ssh_key: SSHKey | BoundSSHKey, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundSSHKey: """Updates an SSH key. You can update an SSH key name and an SSH key labels. :param ssh_key: :class:`BoundSSHKey ` or :class:`SSHKey ` @@ -150,27 +174,24 @@ def update(self, ssh_key, name=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundSSHKey ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data["name"] = name if labels is not None: data["labels"] = labels response = self._client.request( - url="/ssh_keys/{ssh_key_id}".format(ssh_key_id=ssh_key.id), + url=f"{self._base_url}/{ssh_key.id}", method="PUT", json=data, ) return BoundSSHKey(self, response["ssh_key"]) - def delete(self, ssh_key): - # type: (SSHKey) -> bool - self._client.request( - url="/ssh_keys/{ssh_key_id}".format(ssh_key_id=ssh_key.id), method="DELETE" - ) + def delete(self, ssh_key: SSHKey | BoundSSHKey) -> bool: """Deletes an SSH key. It cannot be used anymore. :param ssh_key: :class:`BoundSSHKey ` or :class:`SSHKey ` :return: True """ + 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 c37e63ce..c3fc7c60 100644 --- a/hcloud/ssh_keys/domain.py +++ b/hcloud/ssh_keys/domain.py @@ -1,7 +1,10 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from ..core import BaseDomain, DomainIdentityMixin + +__all__ = [ + "SSHKey", +] class SSHKey(BaseDomain, DomainIdentityMixin): @@ -21,20 +24,28 @@ 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, - id=None, - name=None, - fingerprint=None, - public_key=None, - labels=None, - created=None, + id: int | None = None, + name: str | None = None, + fingerprint: str | None = None, + public_key: str | None = None, + labels: dict[str, str] | None = None, + created: str | None = None, ): self.id = id self.name = name 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 40a96afc..17226236 100644 --- a/hcloud/volumes/__init__.py +++ b/hcloud/volumes/__init__.py @@ -1 +1,13 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations + +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 aed318e3..a4155cf2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -1,59 +1,94 @@ -# -*- coding: utf-8 -*- -from hcloud.core.client import ClientEntityBase, BoundModelBase, GetEntityByNameMixin +from __future__ import annotations -from hcloud.actions.client import BoundAction -from hcloud.core.domain import add_meta_to_result -from hcloud.volumes.domain import Volume, CreateVolumeResponse -from hcloud.locations.client import BoundLocation +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 +from .domain import CreateVolumeResponse, Volume + +if TYPE_CHECKING: + from .._client import Client + from ..locations import Location + from ..servers import BoundServer, Server + + +__all__ = [ + "BoundVolume", + "VolumesPageResult", + "VolumesClient", +] + + +class BoundVolume(BoundModelBase[Volume], Volume): + _client: VolumesClient -class BoundVolume(BoundModelBase): model = Volume - def __init__(self, client, data, complete=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) - from hcloud.servers.client import BoundServer + # 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 ) - super(BoundVolume, self).__init__(client, data, complete) + super().__init__(client, data, complete) - def get_actions_list(self, status=None, sort=None, page=None, per_page=None): - # type: (Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a volume. + 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 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=None, sort=None): - # type: (Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a volume. + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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, name=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundAction + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundVolume: """Updates the volume properties. :param name: str (optional) @@ -62,74 +97,92 @@ def update(self, name=None, labels=None): 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): - # type: () -> BoundAction + 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. :return: boolean """ return self._client.delete(self) - def attach(self, server, automount=None): - # type: (Union[Server, BoundServer]) -> BoundAction + def attach( + self, + server: Server | BoundServer, + automount: bool | None = None, + ) -> BoundAction: """Attaches a volume to a server. Works only if the server is in the same location as the volume. :param server: :class:`BoundServer ` or :class:`Server ` :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): - # type: () -> BoundAction + 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. :return: :class:`BoundAction ` """ return self._client.detach(self) - def resize(self, size): - # type: (int) -> BoundAction + def resize(self, size: int) -> BoundAction: """Changes the size of a volume. Note that downsizing a volume is not possible. :param size: int 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=None): - # type: (Optional[bool]) -> BoundAction + def change_protection(self, delete: bool | None = None) -> BoundAction: """Changes the protection configuration of a volume. :param delete: boolean 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 -class VolumesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "volumes" +class VolumesClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/volumes" - def get_by_id(self, id): - # type: (int) -> volumes.client.BoundVolume + actions: ResourceActionsClient + """Volumes scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get_by_id(self, id: int) -> BoundVolume: """Get a specific volume by its id :param id: int :return: :class:`BoundVolume ` """ - response = self._client.request( - url="/volumes/{volume_id}".format(volume_id=id), method="GET" - ) + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") return BoundVolume(self, response["volume"]) def get_list( - self, name=None, label_selector=None, page=None, per_page=None, status=None - ): - # type: (Optional[str], Optional[str], Optional[int], Optional[int], Optional[List[str]]) -> PageResults[List[BoundVolume], Meta] + self, + name: str | None = None, + label_selector: str | None = None, + page: int | None = None, + per_page: int | None = None, + status: list[str] | None = None, + ) -> VolumesPageResult: """Get a list of volumes from this account :param name: str (optional) @@ -144,7 +197,7 @@ def get_list( Specifies how many results are returned by page :return: (List[:class:`BoundVolume `], :class:`Meta `) """ - params = {} + params: dict[str, Any] = {} if name is not None: params["name"] = name if label_selector is not None: @@ -156,14 +209,17 @@ 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"] ] - return self._add_meta_to_result(volumes, response) + return VolumesPageResult(volumes, Meta.parse_meta(response)) - def get_all(self, label_selector=None, status=None): - # type: (Optional[str], Optional[List[str]]) -> List[BoundVolume] + def get_all( + self, + label_selector: str | None = None, + status: list[str] | None = None, + ) -> list[BoundVolume]: """Get all volumes from this account :param label_selector: @@ -172,31 +228,31 @@ def get_all(self, label_selector=None, status=None): Can be used to filter volumes by their status. The response will only contain volumes matching the status. :return: List[:class:`BoundVolume `] """ - return super(VolumesClient, self).get_all( - label_selector=label_selector, status=status + return self._iter_pages( + self.get_list, + label_selector=label_selector, + status=status, ) - def get_by_name(self, name): - # type: (str) -> BoundVolume + def get_by_name(self, name: str) -> BoundVolume | None: """Get volume by name :param name: str Used to get volume by name. :return: :class:`BoundVolume ` """ - return super(VolumesClient, self).get_by_name(name) + return self._get_first_by(self.get_list, name=name) def create( self, - size, # type: int - name, # type: str - labels=None, # type: Optional[str] - location=None, # type: Optional[Location] - server=None, # type: Optional[Server], - automount=None, # type: Optional[bool], - format=None, # type: Optional[str], - ): - # type: (...) -> CreateVolumeResponse + size: int, + name: str, + labels: str | None = None, + location: Location | None = None, + server: Server | None = None, + automount: bool | None = None, + format: str | None = None, + ) -> CreateVolumeResponse: """Creates a new volume attached to a server. :param size: int @@ -217,13 +273,10 @@ def create( if size <= 0: raise ValueError("size must be greater than 0") - if not (bool(location) ^ bool(server)): + if not bool(location) ^ bool(server): raise ValueError("only one of server or location must be provided") - data = { - "name": name, - "size": size, - } + data: dict[str, Any] = {"name": name, "size": size} if labels is not None: data["labels"] = labels if location is not None: @@ -236,71 +289,69 @@ 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"] ], ) return result def get_actions_list( - self, volume, status=None, sort=None, page=None, per_page=None - ): - # type: (Volume, Optional[List[str]], Optional[int], Optional[int]) -> PageResults[List[BoundAction], Meta] - """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 `) + self, + volume: Volume | BoundVolume, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: """ - params = {} - 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="/volumes/{volume_id}/actions".format(volume_id=volume.id), - 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 add_meta_to_result(actions, response, "actions") - def get_actions(self, volume, status=None, sort=None): - # type: (Union[Volume, BoundVolume], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for a volume. + def get_actions( + self, + volume: Volume | BoundVolume, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + 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 super(VolumesClient, self).get_actions(volume, status=status, sort=sort) + return self._iter_pages( + self.get_actions_list, + volume, + status=status, + sort=sort, + ) - def update(self, volume, name=None, labels=None): - # type:(Union[Volume, BoundVolume], Optional[str], Optional[Dict[str, str]]) -> BoundVolume + def update( + self, + volume: Volume | BoundVolume, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundVolume: """Updates the volume properties. :param volume: :class:`BoundVolume ` or :class:`Volume ` @@ -310,32 +361,28 @@ def update(self, volume, name=None, labels=None): User-defined labels (key-value pairs) :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if name is not None: data.update({"name": name}) if labels is not None: data.update({"labels": labels}) response = self._client.request( - url="/volumes/{volume_id}".format(volume_id=volume.id), + url=f"{self._base_url}/{volume.id}", method="PUT", json=data, ) return BoundVolume(self, response["volume"]) - def delete(self, volume): - # type: (Union[Volume, BoundVolume]) -> BoundAction + def delete(self, volume: Volume | BoundVolume) -> 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. :param volume: :class:`BoundVolume ` or :class:`Volume ` :return: boolean """ - self._client.request( - url="/volumes/{volume_id}".format(volume_id=volume.id), method="DELETE" - ) + self._client.request(url=f"{self._base_url}/{volume.id}", method="DELETE") return True - def resize(self, volume, size): - # type: (Union[Volume, BoundVolume], int) -> BoundAction + def resize(self, volume: Volume | BoundVolume, size: int) -> BoundAction: """Changes the size of a volume. Note that downsizing a volume is not possible. :param volume: :class:`BoundVolume ` or :class:`Volume ` @@ -344,14 +391,18 @@ def resize(self, volume, size): :return: :class:`BoundAction ` """ data = self._client.request( - url="/volumes/{volume_id}/actions/resize".format(volume_id=volume.id), + 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, volume, server, automount=None): - # type: (Union[Volume, BoundVolume], Union[Server, BoundServer], Optional[bool]) -> BoundAction + def attach( + self, + volume: Volume | BoundVolume, + server: Server | BoundServer, + automount: bool | None = None, + ) -> BoundAction: """Attaches a volume to a server. Works only if the server is in the same location as the volume. :param volume: :class:`BoundVolume ` or :class:`Volume ` @@ -359,32 +410,34 @@ def attach(self, volume, server, automount=None): :param automount: boolean :return: :class:`BoundAction ` """ - data = {"server": server.id} + data: dict[str, Any] = {"server": server.id} if automount is not None: data["automount"] = automount data = self._client.request( - url="/volumes/{volume_id}/actions/attach".format(volume_id=volume.id), + 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): - # type: (Union[Volume, BoundVolume]) -> BoundAction + 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. :param volume: :class:`BoundVolume ` or :class:`Volume ` :return: :class:`BoundAction ` """ data = self._client.request( - url="/volumes/{volume_id}/actions/detach".format(volume_id=volume.id), + 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, volume, delete=None): - # type: (Union[Volume, BoundVolume], Optional[bool], Optional[bool]) -> BoundAction + def change_protection( + self, + volume: Volume | BoundVolume, + delete: bool | None = None, + ) -> BoundAction: """Changes the protection configuration of a volume. :param volume: :class:`BoundVolume ` or :class:`Volume ` @@ -392,15 +445,13 @@ def change_protection(self, volume, delete=None): If True, prevents the volume from being deleted :return: :class:`BoundAction ` """ - data = {} + data: dict[str, Any] = {} if delete is not None: data.update({"delete": delete}) response = self._client.request( - url="/volumes/{volume_id}/actions/change_protection".format( - volume_id=volume.id - ), + 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 137f3df7..2ed33338 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -1,7 +1,20 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse +from __future__ import annotations -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from typing import TYPE_CHECKING, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..locations import BoundLocation, Location + from ..servers import BoundServer, Server + from .client import BoundVolume + +__all__ = [ + "Volume", + "VolumeProtection", + "CreateVolumeResponse", +] class Volume(BaseDomain, DomainIdentityMixin): @@ -36,7 +49,7 @@ class Volume(BaseDomain, DomainIdentityMixin): STATUS_AVAILABLE = "available" """Volume Status available""" - __slots__ = ( + __api_properties__ = ( "id", "name", "server", @@ -49,25 +62,26 @@ class Volume(BaseDomain, DomainIdentityMixin): "status", "created", ) + __slots__ = __api_properties__ def __init__( self, - id, - name=None, - server=None, - created=None, - location=None, - size=None, - linux_device=None, - format=None, - protection=None, - labels=None, - status=None, + id: int, + name: str | None = None, + server: Server | BoundServer | None = None, + created: str | None = None, + location: Location | BoundLocation | None = None, + size: int | None = None, + linux_device: str | None = None, + format: str | 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 @@ -77,6 +91,10 @@ def __init__( self.status = status +class VolumeProtection(TypedDict): + delete: bool + + class CreateVolumeResponse(BaseDomain): """Create Volume Response Domain @@ -88,13 +106,14 @@ 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, - volume, # type: BoundVolume - action, # type: BoundAction - next_actions, # type: List[BoundAction] + volume: BoundVolume, + action: BoundAction, + next_actions: list[BoundAction], ): self.volume = volume self.action = action 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 new file mode 100644 index 00000000..cbf37daf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[tool.isort] +profile = "black" +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"] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.pylint.main] +py-version = "3.10" +recursive = true +jobs = 0 + +[tool.pylint.reports] +output-format = "colorized" + +[tool.pylint."messages control"] +disable = [ + "fixme", + "line-too-long", + "missing-class-docstring", + "missing-module-docstring", + "redefined-builtin", + "duplicate-code", + # Consider disabling line-by-line + "too-few-public-methods", + "too-many-public-methods", + "too-many-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-positional-arguments", +] diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..5805c99a --- /dev/null +++ b/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>hetznercloud/.github//renovate/default"] +} diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index 9c558e35..00000000 --- a/requirements/base.txt +++ /dev/null @@ -1 +0,0 @@ -. diff --git a/requirements/test.txt b/requirements/test.txt deleted file mode 100644 index 5e90782d..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,8 +0,0 @@ --r base.txt - -flake8==3.6.0 -isort==4.3.4 -mock==2.0.0 -pytest -tox==3.23.1 -black==21.7b0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index d3d2afb0..00000000 --- a/setup.cfg +++ /dev/null @@ -1,20 +0,0 @@ -[bdist_wheel] -universal = 1 - - -[pep8] -ignore = E501,E722,W503 - -[flake8] -ignore = E501,E722,W503 -exclude = - .git, - docs, - - -[aliases] -test = pytest - -[tool:pytest] -collect_ignore = ['setup.py'] - diff --git a/setup.py b/setup.py index 13f42bca..4ae4e5b9 100644 --- a/setup.py +++ b/setup.py @@ -1,51 +1,61 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +from __future__ import annotations -"""The setup script.""" +from setuptools import find_packages, setup -from setuptools import setup, find_packages - -with open("README.rst") as readme_file: +with open("README.md", encoding="utf-8") as readme_file: readme = readme_file.read() -with open("CHANGELOG.rst") as changelog_file: - changelog = changelog_file.read() - -requirements = ["future>=0.17.1,<1", "python-dateutil>=2.7.5,<3", "requests>=2.20,<3"] - -extras_require = {"docs": ["Sphinx==1.8.1", "sphinx-rtd-theme==0.4.2"]} - -version = {} -with open("hcloud/__version__.py") as fp: - exec(fp.read(), version) - setup( + name="hcloud", + version="2.16.0", # x-releaser-pleaser-version + keywords="hcloud hetzner cloud", + description="Official Hetzner Cloud python library", + long_description=readme, + long_description_content_type="text/markdown", author="Hetzner Cloud GmbH", author_email="support-cloud@hetzner.com", + url="https://github.com/hetznercloud/hcloud-python", + project_urls={ + "Bug Tracker": "https://github.com/hetznercloud/hcloud-python/issues", + "Documentation": "https://hcloud-python.readthedocs.io/en/stable/", + "Changelog": "https://github.com/hetznercloud/hcloud-python/blob/main/CHANGELOG.md", + "Source Code": "https://github.com/hetznercloud/hcloud-python", + }, + 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.6", - "Programming Language :: Python :: 3.7", - "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.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.11", - description="Official Hetzner Cloud python library", - install_requires=requirements, - extras_require=extras_require, - license="MIT license", - long_description=readme + "\n\n" + changelog, + python_requires=">=3.10", + install_requires=[ + "python-dateutil>=2.7.5", + "requests>=2.20", + ], + extras_require={ + "docs": [ + "sphinx>=9,<9.2", + "sphinx-rtd-theme>=3,<3.2", + "myst-parser>=5,<5.1", + "watchdog>=6,<6.1", + ], + "test": [ + "coverage>=7.13,<7.14", + "pylint>=4,<4.1", + "pytest>=9,<9.1", + "pytest-cov>=7,<7.1", + "mypy>=1.19,<1.20", + "types-python-dateutil", + "types-requests", + ], + }, include_package_data=True, - keywords="hcloud hetzner cloud", - name="hcloud", packages=find_packages(exclude=["examples", "tests*", "docs"]), - test_suite="tests", - url="https://github.com/hetznercloud/hcloud-python", - version=version["VERSION"], zip_safe=False, ) diff --git a/tests/unit/actions/conftest.py b/tests/unit/actions/conftest.py deleted file mode 100644 index 2fbd3c5a..00000000 --- a/tests/unit/actions/conftest.py +++ /dev/null @@ -1,77 +0,0 @@ -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 d16ec4a1..2b9d8113 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -1,114 +1,473 @@ -import mock +from __future__ import annotations + +import inspect +from unittest import mock + import pytest -from hcloud.actions.client import ActionsClient, BoundAction -from hcloud.actions.domain import Action, ActionFailedException, ActionTimeoutException +from hcloud import Client +from hcloud.actions import ( + 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. + """ + 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(object): + +class TestBoundAction: @pytest.fixture() - def bound_running_action(self, mocked_requests): - return BoundAction( - client=ActionsClient(client=mocked_requests), - 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() + + 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 -class TestActionsClient(object): @pytest.fixture() - def actions_client(self): - return ActionsClient(client=mock.MagicMock()) + 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, 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" + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: ResourceActionsClient, + resource: str, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/actions/1", + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + 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 not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_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) + + +class TestResourceObjectActionsClient: + """ + ///actions + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + return request.param + + @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}) + + @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 action._client is actions_client - assert action.id == 1 - assert action.command == "stop_server" + + assert result.meta is not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) @pytest.mark.parametrize( "params", - [{}, {"status": ["active"], "sort": ["status"], "page": 2, "per_page": 10}], + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], ) - 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="/actions", method="GET", params=params + 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 None + 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) - action1 = actions[0] - action2 = actions[1] + @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 - assert action1._client is actions_client - assert action1.id == 1 - assert action1.command == "start_server" + actions = bound_model.get_actions(**params) - assert action2._client is actions_client - assert action2.id == 2 - assert action2.command == "stop_server" + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) - @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) - params.update({"page": 1, "per_page": 50}) +class TestActionsClient: + @pytest.fixture() + def actions_client(self, client: Client) -> ActionsClient: + return client.actions - actions_client._client.request.assert_called_with( - url="/actions", method="GET", params=params + def test_get_by_id( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + action_response, + ): + request_mock.return_value = action_response + + action = actions_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/actions/1", ) + assert_bound_action1(action, actions_client) + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + 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) + + request_mock.assert_called_with( + method="GET", + url="/actions", + params=params, + ) + + 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] + @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 - assert action1._client is actions_client - assert action1.id == 1 - assert action1.command == "start_server" + with pytest.deprecated_call(): + actions = actions_client.get_all(**params) - assert action2._client is actions_client - assert action2.id == 2 - assert action2.command == "stop_server" + request_mock.assert_called_with( + method="GET", + url="/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + 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 5278964b..395af182 100644 --- a/tests/unit/actions/test_domain.py +++ b/tests/unit/actions/test_domain.py @@ -1,17 +1,76 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.actions import ( + Action, + ActionException, + ActionFailedException, + ActionTimeoutException, +) -from hcloud.actions.domain import Action +@pytest.mark.parametrize( + "value", + [ + (Action(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestAction(object): + +class TestAction: def test_started_finished_is_datetime(self): action = Action( id=1, started="2016-01-30T23:50+00:00", finished="2016-03-30T23:50+00:00" ) assert action.started == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) assert action.finished == datetime.datetime( - 2016, 3, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 3, 30, 23, 50, tzinfo=timezone.utc + ) + + +def test_action_exceptions(): + with pytest.raises( + ActionException, + match=r"The pending action failed: Server does not exist anymore", + ): + raise ActionFailedException( + action=Action( + **{ + "id": 1084730887, + "command": "change_server_type", + "status": "error", + "progress": 100, + "resources": [{"id": 34574042, "type": "server"}], + "error": { + "code": "server_does_not_exist_anymore", + "message": "Server does not exist anymore", + }, + "started": "2023-07-06T14:52:42+00:00", + "finished": "2023-07-06T14:53:08+00:00", + } + ) + ) + + with pytest.raises(ActionException, match=r"The pending action timed out"): + raise ActionTimeoutException( + action=Action( + **{ + "id": 1084659545, + "command": "create_server", + "status": "running", + "progress": 50, + "started": "2023-07-06T13:58:38+00:00", + "finished": None, + "resources": [{"id": 34572291, "type": "server"}], + "error": None, + } + ) ) diff --git a/tests/unit/certificates/conftest.py b/tests/unit/certificates/conftest.py index 5fdc5b5f..a13873da 100644 --- a/tests/unit/certificates/conftest.py +++ b/tests/unit/certificates/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py index b2acd4e5..8182bb48 100644 --- a/tests/unit/certificates/test_client.py +++ b/tests/unit/certificates/test_client.py @@ -1,116 +1,73 @@ -import pytest -import mock - -from hcloud.actions.client import BoundAction -from hcloud.certificates.client import CertificatesClient, BoundCertificate -from hcloud.certificates.domain import Certificate, ManagedCertificateStatus - - -class TestBoundCertificate(object): - @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 +from __future__ import annotations - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0].id == 13 - assert actions[0].command == "change_protection" +from unittest import mock - 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} +import pytest - hetzner_client.request.assert_called_with( - url="/certificates/14/actions", method="GET", params=params - ) +from hcloud import Client +from hcloud.certificates import ( + BoundCertificate, + Certificate, + CertificatesClient, + ManagedCertificateStatus, +) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0].id == 13 - assert actions[0].command == "change_protection" +from ..conftest import BoundModelTestCase - 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" +class TestBoundCertificate(BoundModelTestCase): + methods = [ + BoundCertificate.update, + BoundCertificate.delete, + BoundCertificate.retry_issuance, + ] - 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" - - 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" - - -class TestCertificatesClient(object): + 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 @@ -129,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 @@ -153,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 @@ -175,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...", @@ -210,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"], @@ -238,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 @@ -253,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 @@ -267,14 +280,19 @@ 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 diff --git a/tests/unit/certificates/test_domain.py b/tests/unit/certificates/test_domain.py index e558aa32..3c3b2517 100644 --- a/tests/unit/certificates/test_domain.py +++ b/tests/unit/certificates/test_domain.py @@ -1,10 +1,30 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.certificates import ( + Certificate, + ManagedCertificateError, + ManagedCertificateStatus, +) + -from hcloud.certificates.domain import Certificate +@pytest.mark.parametrize( + "value", + [ + (Certificate(id=1),), + (ManagedCertificateError()), + (ManagedCertificateStatus()), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestCertificate(object): +class TestCertificate: def test_created_is_datetime(self): certificate = Certificate( id=1, @@ -13,11 +33,11 @@ def test_created_is_datetime(self): not_valid_before="2016-01-30T23:50+00:00", ) assert certificate.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) assert certificate.not_valid_after == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) assert certificate.not_valid_before == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 17c9d624..65446f4a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,36 +1,252 @@ -import mock +# 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(autouse=True, scope="function") -def mocked_requests(): - patcher = mock.patch("hcloud.hcloud.requests") - mocked_requests = patcher.start() - yield mocked_requests - patcher.stop() +@pytest.fixture() +def action1_success(action1_running): + return { + **action1_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + } @pytest.fixture() -def generic_action(): +def action2_success(action2_running): return { - "action": { - "id": 1, - "command": "stop_server", - "status": "running", - "progress": 0, - "started": "2016-01-30T23:50+00:00", - "finished": None, - "resources": [{"id": 42, "type": "server"}], - "error": {"code": "action_failed", "message": "Action failed"}, - } + **action2_running, + "status": "success", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", } @pytest.fixture() -def hetzner_client(): - client = Client(token="token") - patcher = mock.patch.object(client, "request") - patcher.start() - yield client - patcher.stop() +def action1_error(action1_running): + return { + **action1_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } + + +@pytest.fixture() +def action2_error(action2_running): + return { + **action2_running, + "status": "error", + "progress": 100, + "finished": "2016-01-31T00:10+00:00", + "error": {"code": "action_failed", "message": "Action failed"}, + } + + +@pytest.fixture() +def action_response(action1_running): + return { + "action": action1_running, + } + + +@pytest.fixture() +def 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 feabf820..6e9561fd 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -1,22 +1,27 @@ -import mock +from __future__ import annotations + +from typing import Any, NamedTuple +from unittest import mock + import pytest -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result, BaseDomain +from hcloud.actions import ActionsPageResult +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 self.name = name self.description = description - class BoundModel(BoundModelBase): + class BoundModel(BoundModelBase, Model): model = Model return BoundModel @@ -42,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( @@ -72,24 +77,43 @@ 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) + + # Comparing a bound model with a base domain + assert bound_model_a == bound_model_a.data_model + + # Identical bound models + assert bound_model_a == bound_model_b + assert bound_model_a == bound_model_b.data_model + + # Differing bound models + bound_model_b.data_model.name = "changed_name" + assert bound_model_a != bound_model_b + assert bound_model_a != bound_model_b.data_model + -class TestClientEntityBase: +class TestResourceClientBase: @pytest.fixture() def client_class_constructor(self): def constructor(json_content_function): - class CandiesClient(ClientEntityBase): - results_list_attribute_name = "candies" + class CandiesPageResult(NamedTuple): + candies: list[Any] + meta: Meta - def get_list(self, status, page=None, per_page=None): + class CandiesClient(ResourceClientBase): + def get_list(self, status=None, page=None, per_page=None): json_content = json_content_function(page) results = [ (r, page, status, per_page) for r in json_content["candies"] ] - return self._add_meta_to_result(results, json_content) + return CandiesPageResult(results, Meta.parse_meta(json_content)) return CandiesClient(mock.MagicMock()) @@ -98,46 +122,46 @@ def get_list(self, status, 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 = [ (r, page, status, per_page) for r in json_content["actions"] ] - return add_meta_to_result(results, json_content, "actions") + return ActionsPageResult(results, Meta.parse_meta(json_content)) return CandiesClient(mock.MagicMock()) return constructor - def test_get_all_no_meta(self, client_class_constructor): + 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) - result = candies_client.get_all(status="sweet") + result = candies_client._iter_pages(candies_client.get_list, status="sweet") assert result == [(1, 1, "sweet", 50), (2, 1, "sweet", 50)] - def test_get_all_no_next_page(self, client_class_constructor): + def test_iter_pages_no_next_page(self, client_class_constructor): json_content = { "candies": [1, 2], "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) - result = candies_client.get_all(status="sweet") + result = candies_client._iter_pages(candies_client.get_list, status="sweet") assert result == [(1, 1, "sweet", 50), (2, 1, "sweet", 50)] - def test_get_all_ok(self, client_class_constructor): + def test_iter_pages_ok(self, client_class_constructor): def json_content_function(p): return { "candies": [10 + p, 20 + p], @@ -152,7 +176,7 @@ def json_content_function(p): candies_client = client_class_constructor(json_content_function) - result = candies_client.get_all(status="sweet") + result = candies_client._iter_pages(candies_client.get_list, status="sweet") assert result == [ (11, 1, "sweet", 50), @@ -163,19 +187,6 @@ def json_content_function(p): (23, 3, "sweet", 50), ] - def test_get_actions_no_method(self, client_class_constructor): - json_content = {"candies": [1, 2]} - - def json_content_function(p): - return json_content - - candies_client = client_class_constructor(json_content_function) - - with pytest.raises(ValueError) as exception_info: - candies_client.get_actions() - error = exception_info.value - assert str(error) == "this endpoint does not support get_actions method" - def test_get_actions_ok(self, client_class_with_actions_constructor): def json_content_function(p): return { @@ -191,7 +202,9 @@ def json_content_function(p): candies_client = client_class_with_actions_constructor(json_content_function) - result = candies_client.get_actions(status="sweet") + result = candies_client._iter_pages( + candies_client.get_actions_list, status="sweet" + ) assert result == [ (11, 1, "sweet", 50), @@ -202,69 +215,26 @@ def json_content_function(p): (23, 3, "sweet", 50), ] - def test_raise_exception_if_list_attribute_is_not_implemented( - self, client_class_with_actions_constructor - ): - def json_content_function(p): - return { - "actions": [10 + p, 20 + p], - "meta": { - "pagination": { - "page": p, - "per_page": 11, - "next_page": p + 1 if p < 3 else None, - } - }, - } - - candies_client = client_class_with_actions_constructor(json_content_function) - - with pytest.raises(NotImplementedError) as exception_info: - candies_client.get_all() - - error = exception_info.value - assert ( - str(error) - == "in order to get results list, 'results_list_attribute_name' attribute of CandiesClient has to be specified" - ) - - -class TestGetEntityByNameMixin: - @pytest.fixture() - def client_class_constructor(self): - def constructor(json_content_function): - class CandiesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = "candies" - - def get_list(self, name, page=None, per_page=None): - json_content = json_content_function(page) - results = json_content["candies"] - return self._add_meta_to_result(results, json_content) - - return CandiesClient(mock.MagicMock()) - - return constructor - - def test_get_by_name_result_exists(self, client_class_constructor): + 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_by_name(name="sweet") + result = candies_client._get_first_by(candies_client.get_list, status="sweet") - assert result == 1 + assert result == (1, None, "sweet", None) - def test_get_by_name_result_does_not_exist(self, client_class_constructor): + 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_by_name(name="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 988f54bb..bfef1129 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -1,20 +1,16 @@ +from __future__ import annotations + import pytest from dateutil.parser import isoparse -from hcloud.core.domain import ( - BaseDomain, - DomainIdentityMixin, - Meta, - Pagination, - add_meta_to_result, -) +from hcloud.core import BaseDomain, DomainIdentityMixin, Meta, Pagination -class TestMeta(object): +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": {}} @@ -44,37 +40,17 @@ def test_parse_meta_json_ok(self): assert result.pagination.last_page == 10 assert result.pagination.total_entries == 100 - def test_add_meta_to_result(self): - json_content = { - "meta": { - "pagination": { - "page": 2, - "per_page": 10, - "previous_page": 1, - "next_page": 3, - "last_page": 10, - "total_entries": 100, - } - } - } - result = add_meta_to_result([1, 2, 3], json_content, "id_list") - assert result.id_list == [1, 2, 3] - assert result.meta.pagination.page == 2 - assert result.meta.pagination.per_page == 10 - assert result.meta.pagination.next_page == 3 - assert result.meta.pagination.last_page == 10 - assert result.meta.pagination.total_entries == 100 - class SomeDomain(BaseDomain, DomainIdentityMixin): - __slots__ = ("id", "name") + __api_properties__ = ("id", "name") + __slots__ = __api_properties__ def __init__(self, id=None, name=None): self.id = id self.name = name -class TestDomainIdentityMixin(object): +class TestDomainIdentityMixin: @pytest.mark.parametrize( "domain,expected_result", [ @@ -90,21 +66,53 @@ def test_id_or_name_exception(self): domain = SomeDomain() with pytest.raises(ValueError) as exception_info: - domain.id_or_name + _ = domain.id_or_name error = exception_info.value assert str(error) == "id or name must be set" + @pytest.mark.parametrize( + "domain, id_or_name, expected", + [ + (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( + 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): + __api_properties__ = ("id", "name", "child") + __slots__ = __api_properties__ + + def __init__(self, id=None, name=None, child=None): + self.id = id + self.name = name + self.child = child -class TestBaseDomain(object): +class TestBaseDomain: @pytest.mark.parametrize( "data_dict,expected_result", [ @@ -134,3 +142,52 @@ def test_from_dict_ok(self, data_dict, expected_result): model = ActionDomain.from_dict(data_dict) for k, v in expected_result.items(): assert getattr(model, k) == v + + @pytest.mark.parametrize( + "data,expected", + [ + ( + SomeOtherDomain(id=1, name="name1"), + "SomeOtherDomain(id=1, name='name1', child=None)", + ), + ( + SomeOtherDomain( + id=2, + name="name2", + child=SomeOtherDomain(id=3, name="name3"), + ), + "SomeOtherDomain(id=2, name='name2', child=SomeOtherDomain(id=3, name='name3', child=None))", + ), + ], + ) + 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 diff --git a/tests/unit/datacenters/conftest.py b/tests/unit/datacenters/conftest.py index f775869e..68d92b6b 100644 --- a/tests/unit/datacenters/conftest.py +++ b/tests/unit/datacenters/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 3e24e92f..7f5ca96b 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -1,12 +1,15 @@ +from __future__ import annotations + +from unittest import mock # noqa: F401 + import pytest # noqa: F401 -import mock # noqa: F401 -from hcloud.datacenters.client import DatacentersClient, BoundDatacenter -from hcloud.datacenters.domain import DatacenterServerTypes -from hcloud.locations.client import BoundLocation +from hcloud import Client +from hcloud.datacenters import BoundDatacenter, DatacentersClient, DatacenterServerTypes +from hcloud.locations import BoundLocation -class TestBoundDatacenter(object): +class TestBoundDatacenter: def test_bound_datacenter_init(self, datacenter_response): bound_datacenter = BoundDatacenter( client=mock.MagicMock(), data=datacenter_response["datacenter"] @@ -54,16 +57,24 @@ def test_bound_datacenter_init(self, datacenter_response): ) -class TestDatacentersClient(object): +class TestDatacentersClient: @pytest.fixture() - def datacenters_client(self): - return DatacentersClient(client=mock.MagicMock()) + def datacenters_client(self, client: Client): + return DatacentersClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + datacenter_response, + ): + request_mock.return_value = datacenter_response - 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 @@ -72,15 +83,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 @@ -98,13 +119,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 @@ -122,13 +153,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..58666bf6 --- /dev/null +++ b/tests/unit/datacenters/test_domain.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import pytest + +from hcloud.datacenters import Datacenter, DatacenterServerTypes + + +@pytest.mark.parametrize( + "value", + [ + (Datacenter(id=1),), + (DatacenterServerTypes(available=[], available_for_migration=[], supported=[])), + ], +) +def test_eq(value): + assert value.__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/conftest.py b/tests/unit/firewalls/conftest.py index 9bee2d81..26f8b853 100644 --- a/tests/unit/firewalls/conftest.py +++ b/tests/unit/firewalls/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -190,7 +192,7 @@ def one_firewalls_response(): } ], "applied_to": [{"server": {"id": 42}, "type": "server"}], - }, + } ] } diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py index ab564eaf..79313c15 100644 --- a/tests/unit/firewalls/test_client.py +++ b/tests/unit/firewalls/test_client.py @@ -1,41 +1,56 @@ +from __future__ import annotations + +from unittest import mock + import pytest -import mock -from hcloud.firewalls.client import FirewallsClient, BoundFirewall -from hcloud.actions.client import BoundAction -from hcloud.firewalls.domain import ( +from hcloud import Client +from hcloud.firewalls import ( + BoundFirewall, Firewall, - FirewallRule, FirewallResource, FirewallResourceLabelSelector, + FirewallRule, + FirewallsClient, ) -from hcloud.servers.domain import Server +from hcloud.servers import Server +from ..conftest import BoundModelTestCase -class TestBoundFirewall(object): - @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 @@ -51,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 @@ -67,137 +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].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 +class TestFirewallsClient: + @pytest.fixture() + def firewalls_client(self, client: Client): + return FirewallsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall_response, ): - hetzner_client.request.return_value = response_get_actions - actions = bound_firewall.get_actions(**params) + request_mock.return_value = firewall_response - 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].id == 13 - assert actions[0].command == "set_firewall_rules" + firewall = firewalls_client.get_by_id(1) - 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( + request_mock.assert_called_with( + method="GET", 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(object): - @pytest.fixture() - def firewalls_client(self): - return FirewallsClient(client=mock.MagicMock()) - - 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" ) assert firewall._client is firewalls_client assert firewall.id == 38 @@ -217,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 @@ -251,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 @@ -274,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=[ @@ -329,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": [ @@ -360,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": {}}, ) @@ -377,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, [ @@ -386,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", + }, ] }, ) @@ -409,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 @@ -420,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}}]}, ) @@ -440,17 +403,22 @@ 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}}]}, ) diff --git a/tests/unit/firewalls/test_domain.py b/tests/unit/firewalls/test_domain.py index a4edc731..7924fbb7 100644 --- a/tests/unit/firewalls/test_domain.py +++ b/tests/unit/firewalls/test_domain.py @@ -1,12 +1,36 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.firewalls import ( + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + FirewallResourceLabelSelector, + FirewallRule, +) + -from hcloud.firewalls.domain import Firewall +@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(object): +class TestFirewall: def test_created_is_datetime(self): firewall = Firewall(id=1, created="2016-01-30T23:50+00:00") assert firewall.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/floating_ips/conftest.py b/tests/unit/floating_ips/conftest.py index d4641e80..8165c3b6 100644 --- a/tests/unit/floating_ips/conftest.py +++ b/tests/unit/floating_ips/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -54,7 +56,7 @@ def one_floating_ips_response(): "blocked": False, "protection": {"delete": False}, "labels": {}, - }, + } ] } diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index e8adcca7..cc271c66 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -1,151 +1,98 @@ -import pytest -import mock - -from hcloud.actions.client import BoundAction -from hcloud.servers.client import BoundServer -from hcloud.servers.domain import Server -from hcloud.floating_ips.client import FloatingIPsClient, BoundFloatingIP -from hcloud.floating_ips.domain import FloatingIP -from hcloud.locations.client import BoundLocation -from hcloud.locations.domain import Location - - -class TestBoundFloatingIP(object): - @pytest.fixture() - def bound_floating_ip(self, hetzner_client): - return BoundFloatingIP(client=hetzner_client.floating_ips, data=dict(id=14)) +from __future__ import annotations - def test_bound_floating_ip_init(self, floating_ip_response): - bound_floating_ip = BoundFloatingIP( - client=mock.MagicMock(), data=floating_ip_response["floating_ip"] - ) +from unittest import mock - 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].id == 13 - assert actions[0].command == "assign_floating_ip" - - 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" +import pytest - 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" - ) +from hcloud import Client +from hcloud.floating_ips import BoundFloatingIP, FloatingIP, FloatingIPsClient +from hcloud.locations import BoundLocation, Location +from hcloud.servers import BoundServer, Server - 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}, - ) +from ..conftest import BoundModelTestCase - 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 +class TestBoundFloatingIP(BoundModelTestCase): + methods = [ + BoundFloatingIP.update, + BoundFloatingIP.delete, + BoundFloatingIP.change_protection, + BoundFloatingIP.change_dns_ptr, + BoundFloatingIP.assign, + BoundFloatingIP.unassign, + ] - 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" - ) - assert action.id == 1 - assert action.progress == 0 + @pytest.fixture() + def resource_client(self, client: Client): + return client.floating_ips - 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"}, + @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 - -class TestFloatingIPsClient(object): + 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 @@ -156,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 @@ -180,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 @@ -203,16 +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"), + "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", @@ -232,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 @@ -251,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", @@ -279,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"}, ) @@ -321,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}, ) @@ -336,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 @@ -355,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 @@ -368,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 @@ -381,14 +384,22 @@ 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 diff --git a/tests/unit/floating_ips/test_domain.py b/tests/unit/floating_ips/test_domain.py index 167540aa..98be19e6 100644 --- a/tests/unit/floating_ips/test_domain.py +++ b/tests/unit/floating_ips/test_domain.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.floating_ips import FloatingIP + -from hcloud.floating_ips.domain import FloatingIP +@pytest.mark.parametrize( + "value", + [ + (FloatingIP(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestFloatingIP(object): +class TestFloatingIP: def test_created_is_datetime(self): - floatingIP = FloatingIP(id=1, created="2016-01-30T23:50+00:00") - assert floatingIP.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 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/helpers/test_labels.py b/tests/unit/helpers/test_labels.py new file mode 100644 index 00000000..f5494cb0 --- /dev/null +++ b/tests/unit/helpers/test_labels.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import pytest + +from hcloud.helpers.labels import LabelValidator + + +@pytest.mark.parametrize( + "labels,expected", + [ + # valid combinations + ({"label1": "correct.de"}, True), + ({"empty/label": ""}, True), + ({"label3-test.de/hallo.welt": "233344444443"}, True), + ({"label2.de/hallo": "1correct2.de"}, True), + # invalid value + ({"valid_key": "incorrect .com"}, False), + ({"valid_key": "-incorrect.com"}, False), + ({"valid_key": "incorrect.com-"}, False), + ({"valid_key": "incorr,ect.com-"}, False), + ( + { + "valid_key": "incorrect-111111111111111111111111111111111111111111111111111111111111.com" + }, + False, + ), + ( + { + "valid_key": "63-characters-are-allowed-in-a-label__this-is-one-character-more", + }, + False, + ), + # invalid keys + ({"incorrect.de/": "correct.de"}, False), + ({"incor rect.de/": "correct.de"}, False), + ({"incorrect.de/+": "correct.de"}, False), + ({"-incorrect.de": "correct.de"}, False), + ({"incorrect.de-": "correct.de"}, False), + ({"incorrect.de/tes t": "correct.de"}, False), + ({"incorrect.de/test-": "correct.de"}, False), + ( + { + "incorrect.de/test-dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd": "correct.de" + }, + False, + ), + ( + { + "incorrect-11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + ".de/test": "correct.de" + }, + False, + ), + ], +) +def test_validate(labels, expected): + assert LabelValidator.validate(labels=labels) == expected + + +@pytest.mark.parametrize( + "labels,expected,type", + [ + # valid combinations + ({"label1": "correct.de"}, True, ""), + ({"empty/label": ""}, True, ""), + ({"label3-test.de/hallo.welt": "233344444443"}, True, ""), + ({"label2.de/hallo": "1correct2.de"}, True, ""), + # invalid value + ({"valid_key": "incorrect .com"}, False, "value"), + ({"valid_key": "-incorrect.com"}, False, "value"), + ({"valid_key": "incorrect.com-"}, False, "value"), + ({"valid_key": "incorr,ect.com-"}, False, "value"), + ( + { + "valid_key": "incorrect-111111111111111111111111111111111111111111111111111111111111.com" + }, + False, + "value", + ), + ( + { + "valid_key": "63-characters-are-allowed-in-a-label__this-is-one-character-more", + }, + False, + "value", + ), + # invalid keys + ({"incorrect.de/": "correct.de"}, False, "key"), + ({"incor rect.de/": "correct.de"}, False, "key"), + ({"incorrect.de/+": "correct.de"}, False, "key"), + ({"-incorrect.de": "correct.de"}, False, "key"), + ({"incorrect.de-": "correct.de"}, False, "key"), + ({"incorrect.de/tes t": "correct.de"}, False, "key"), + ({"incorrect.de/test-": "correct.de"}, False, "key"), + ( + { + "incorrect.de/test-dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd": "correct.de" + }, + False, + "key", + ), + ( + { + "incorrect-11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + "11111111111111111111111111111111111111111111111111111111111111111111111111111111" + + ".de/test": "correct.de" + }, + False, + "key", + ), + ], +) +def test_validate_verbose(labels, expected, type): + result, error = LabelValidator.validate_verbose(labels=labels) + if type == "key" and expected is False: + assert error == f"label key {list(labels.keys())[0]} is not correctly formatted" + elif type == "value" and expected is False: + assert ( + error + == f"label value {list(labels.values())[0]} (key: {list(labels.keys())[0]}) is not correctly formatted" + ) + + assert result == expected diff --git a/tests/unit/images/conftest.py b/tests/unit/images/conftest.py index 0c3e56bd..0917fbae 100644 --- a/tests/unit/images/conftest.py +++ b/tests/unit/images/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -17,6 +19,7 @@ def image_response(): "bound_to": 1, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", @@ -42,6 +45,7 @@ def two_images_response(): "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", @@ -60,6 +64,7 @@ def two_images_response(): "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", @@ -86,11 +91,12 @@ def one_images_response(): "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", "labels": {}, - }, + } ] } @@ -111,6 +117,7 @@ def response_update_image(): "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "arm", "rapid_deploy": False, "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 521ad1f8..d88b36bf 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -1,20 +1,34 @@ -import pytest -import mock +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.images import BoundImage, Image, ImagesClient +from hcloud.servers import BoundServer -from hcloud.images.client import ImagesClient, BoundImage -from hcloud.actions.client import BoundAction -from hcloud.images.domain import Image -from hcloud.servers.client import BoundServer +from ..conftest import BoundModelTestCase -class TestBoundImage(object): +class TestBoundImage(BoundModelTestCase): + methods = [ + BoundImage.update, + BoundImage.delete, + BoundImage.change_protection, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.images + @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 @@ -25,13 +39,14 @@ def test_bound_image_init(self, image_response): assert bound_image.image_size == 2.3 assert bound_image.disk_size == 10 assert bound_image.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) assert bound_image.os_flavor == "ubuntu" assert bound_image.os_version == "16.04" + assert bound_image.architecture == "x86" assert bound_image.rapid_deploy is False assert bound_image.deprecated == datetime.datetime( - 2018, 2, 28, 0, 0, tzinfo=tzoffset(None, 0) + 2018, 2, 28, 0, 0, tzinfo=timezone.utc ) assert isinstance(bound_image.created_from, BoundServer) @@ -43,91 +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].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 +class TestImagesClient: + @pytest.fixture() + def images_client(self, client: Client): + return ImagesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image_response, ): - 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].id == 13 - assert actions[0].command == "change_protection" + request_mock.return_value = image_response - 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 + image = images_client.get_by_id(1) - 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}, + request_mock.assert_called_with( + method="GET", + url="/images/1", ) - - assert action.id == 1 - assert action.progress == 0 - - -class TestImagesClient(object): - @pytest.fixture() - def images_client(self): - return ImagesClient(client=mock.MagicMock()) - - 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") assert image._client is images_client assert image.id == 4711 assert image.name == "ubuntu-20.04" @@ -149,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 @@ -186,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 @@ -209,51 +178,71 @@ 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" - @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={} - ) + 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 - actions = result.actions - assert result.meta is None + image = images_client.get_by_name_and_architecture("ubuntu-20.04", "x86") - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) + params = {"name": "ubuntu-20.04", "architecture": ["x86"]} - assert actions[0]._client == images_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + 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" + assert image.architecture == "x86" @pytest.mark.parametrize( "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] ) - def test_update(self, images_client, image, response_update_image): - images_client._client.request.return_value = response_update_image + def test_update( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + response_update_image, + ): + request_mock.return_value = response_update_image + image = images_client.update( image, description="My new Image description", type="snapshot", labels={} ) - images_client._client.request.assert_called_with( - url="/images/1", + + request_mock.assert_called_with( method="PUT", + url="/images/1", json={ "description": "My new Image description", "type": "snapshot", @@ -267,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}, ) @@ -282,11 +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 + def test_delete( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image, + action_response, + ): + request_mock.return_value = action_response + delete_success = images_client.delete(image) - images_client._client.request.assert_called_with( - url="/images/1", method="DELETE" + + request_mock.assert_called_with( + method="DELETE", + url="/images/1", ) assert delete_success is True diff --git a/tests/unit/images/test_domain.py b/tests/unit/images/test_domain.py index 733d45b2..4ff6c133 100644 --- a/tests/unit/images/test_domain.py +++ b/tests/unit/images/test_domain.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.images import Image + -from hcloud.images.domain import Image +@pytest.mark.parametrize( + "value", + [ + (Image(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestImage(object): +class TestImage: def test_created_is_datetime(self): image = Image(id=1, created="2016-01-30T23:50+00:00") assert image.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/isos/conftest.py b/tests/unit/isos/conftest.py index 4c27b019..3052f4ac 100644 --- a/tests/unit/isos/conftest.py +++ b/tests/unit/isos/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -9,7 +11,12 @@ def iso_response(): "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", + "architecture": "x86", "deprecated": "2018-02-28T00:00:00+00:00", + "deprecation": { + "announced": "2018-01-28T00:00:00+00:00", + "unavailable_after": "2018-02-28T00:00:00+00:00", + }, } } @@ -23,14 +30,20 @@ def two_isos_response(): "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", + "architecture": "x86", "deprecated": "2018-02-28T00:00:00+00:00", + "deprecation": { + "announced": "2018-01-28T00:00:00+00:00", + "unavailable_after": "2018-02-28T00:00:00+00:00", + }, }, { "id": 4712, "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", - "deprecated": "2018-02-28T00:00:00+00:00", + "architecture": "x86", + "deprecated": None, }, ] } @@ -45,7 +58,12 @@ def one_isos_response(): "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", + "architecture": "x86", "deprecated": "2018-02-28T00:00:00+00:00", + "deprecation": { + "announced": "2018-01-28T00:00:00+00:00", + "unavailable_after": "2018-02-28T00:00:00+00:00", + }, } ] } diff --git a/tests/unit/isos/test_client.py b/tests/unit/isos/test_client.py index 32a2a51a..3735f26d 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -1,15 +1,19 @@ -import pytest -import mock +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone +from unittest import mock + +import pytest -from hcloud.isos.client import IsosClient, BoundIso +from hcloud import Client +from hcloud.isos import BoundIso, IsosClient -class TestBoundIso(object): +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"]) @@ -18,20 +22,38 @@ def test_bound_iso_init(self, iso_response): assert bound_iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" assert bound_iso.description == "FreeBSD 11.0 x64" assert bound_iso.type == "public" - assert bound_iso.deprecated == datetime.datetime( - 2018, 2, 28, 0, 0, tzinfo=tzoffset(None, 0) + assert bound_iso.architecture == "x86" + with pytest.deprecated_call(): + assert bound_iso.deprecated == datetime.datetime( + 2018, 2, 28, 0, 0, tzinfo=timezone.utc + ) + assert bound_iso.deprecation.announced == datetime.datetime( + 2018, 1, 28, 0, 0, tzinfo=timezone.utc + ) + assert bound_iso.deprecation.unavailable_after == datetime.datetime( + 2018, 2, 28, 0, 0, tzinfo=timezone.utc ) -class TestIsosClient(object): +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" @@ -44,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 @@ -70,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 @@ -93,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 6396a984..f6c72a15 100644 --- a/tests/unit/isos/test_domain.py +++ b/tests/unit/isos/test_domain.py @@ -1,12 +1,49 @@ -import datetime -from dateutil.tz import tzoffset +from __future__ import annotations -from hcloud.isos.domain import Iso +from datetime import datetime, timezone +import pytest -class TestIso(object): - def test_deprecated_is_datetime(self): - iso = Iso(id=1, deprecated="2016-01-30T23:50+00:00") - assert iso.deprecated == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) +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): + return Iso( + **{ + "id": 10433, + "name": "vyos-1.4-rolling-202111150317-amd64.iso", + "description": "VyOS 1.4 (amd64)", + "type": "public", + "deprecation": { + "announced": "2023-10-05T08:27:01Z", + "unavailable_after": "2023-11-05T08:27:01Z", + }, + "architecture": "x86", + "deprecated": "2023-11-05T08:27:01Z", + } + ) + + def test_deprecation(self, deprecated_iso: Iso): + with pytest.deprecated_call(): + assert deprecated_iso.deprecated == datetime( + 2023, 11, 5, 8, 27, 1, tzinfo=timezone.utc + ) + assert deprecated_iso.deprecation is not None + assert deprecated_iso.deprecation.announced == datetime( + 2023, 10, 5, 8, 27, 1, tzinfo=timezone.utc + ) + assert deprecated_iso.deprecation.unavailable_after == datetime( + 2023, 11, 5, 8, 27, 1, tzinfo=timezone.utc ) diff --git a/tests/unit/load_balancer_types/conftest.py b/tests/unit/load_balancer_types/conftest.py index 7e159c2e..0736d787 100644 --- a/tests/unit/load_balancer_types/conftest.py +++ b/tests/unit/load_balancer_types/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py index 565ce61b..c9f356dc 100644 --- a/tests/unit/load_balancer_types/test_client.py +++ b/tests/unit/load_balancer_types/test_client.py @@ -1,22 +1,31 @@ -import pytest -import mock +from __future__ import annotations + +from unittest import mock +import pytest -from hcloud.load_balancer_types.client import LoadBalancerTypesClient +from hcloud import Client +from hcloud.load_balancer_types import LoadBalancerTypesClient -class TestLoadBalancerTypesClient(object): +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_type_response, + ): + request_mock.return_value = load_balancer_type_response - 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 - ) load_balancer_type = load_balancer_types_client.get_by_id(1) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types/1", method="GET" + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types/1", ) assert load_balancer_type._client is load_balancer_types_client assert load_balancer_type.id == 1 @@ -26,18 +35,24 @@ def test_get_by_id(self, load_balancer_types_client, load_balancer_type_response "params", [{"name": "LB11", "page": 1, "per_page": 10}, {"name": ""}, {}] ) def test_get_list( - self, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + two_load_balancer_types_response, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) + request_mock.return_value = two_load_balancer_types_response + result = load_balancer_types_client.get_list(**params) - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types", method="GET", params=params + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, ) load_balancer_types = result.load_balancer_types - assert result.meta is None + assert result.meta is not None assert len(load_balancer_types) == 2 @@ -54,17 +69,22 @@ def test_get_list( @pytest.mark.parametrize("params", [{"name": "LB21"}]) def test_get_all( - self, load_balancer_types_client, two_load_balancer_types_response, params + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + two_load_balancer_types_response, + params, ): - load_balancer_types_client._client.request.return_value = ( - two_load_balancer_types_response - ) + request_mock.return_value = two_load_balancer_types_response + load_balancer_types = load_balancer_types_client.get_all(**params) params.update({"page": 1, "per_page": 50}) - load_balancer_types_client._client.request.assert_called_with( - 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 @@ -81,17 +101,21 @@ def test_get_all( assert load_balancer_types2.name == "LB21" def test_get_by_name( - self, load_balancer_types_client, one_load_balancer_types_response + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + one_load_balancer_types_response, ): - load_balancer_types_client._client.request.return_value = ( - one_load_balancer_types_response - ) + request_mock.return_value = one_load_balancer_types_response + load_balancer_type = load_balancer_types_client.get_by_name("LB21") params = {"name": "LB21"} - load_balancer_types_client._client.request.assert_called_with( - url="/load_balancer_types", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, ) assert load_balancer_type._client is load_balancer_types_client 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 597bb919..f19508ea 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -442,6 +444,26 @@ def response_simple_load_balancers(): } +@pytest.fixture() +def response_get_metrics(): + return { + "metrics": { + "start": "2023-12-14T16:55:32+01:00", + "end": "2023-12-14T17:25:32+01:00", + "step": 9.0, + "time_series": { + "requests_per_second": { + "values": [ + [1702571114, "0.000000"], + [1702571123, "0.000000"], + [1702571132, "0.000000"], + ] + } + }, + } + } + + @pytest.fixture() def response_add_service(): return { diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 97a765a4..7ac3290c 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -1,31 +1,58 @@ -import mock -import pytest +from __future__ import annotations -from hcloud.load_balancer_types.domain import LoadBalancerType -from hcloud.locations.domain import Location -from hcloud.networks.domain import Network -from hcloud.servers.domain import Server +from unittest import mock -from hcloud.load_balancers.client import BoundLoadBalancer, LoadBalancersClient +import pytest -from hcloud.load_balancers.domain import ( +from hcloud import Client +from hcloud.load_balancer_types import LoadBalancerType +from hcloud.load_balancers import ( + BoundLoadBalancer, + LoadBalancer, LoadBalancerAlgorithm, LoadBalancerHealthCheck, + LoadBalancersClient, LoadBalancerService, LoadBalancerTarget, - LoadBalancer, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, ) -from hcloud.actions.client import BoundAction +from hcloud.locations import Location +from hcloud.networks import Network +from hcloud.servers import Server + +from ..conftest import BoundModelTestCase + + +class TestBoundLoadBalancer(BoundModelTestCase): + methods = [ + BoundLoadBalancer.update, + BoundLoadBalancer.delete, + BoundLoadBalancer.change_algorithm, + BoundLoadBalancer.change_dns_ptr, + BoundLoadBalancer.change_protection, + BoundLoadBalancer.change_type, + BoundLoadBalancer.add_service, + BoundLoadBalancer.update_service, + BoundLoadBalancer.delete_service, + BoundLoadBalancer.add_target, + BoundLoadBalancer.remove_target, + BoundLoadBalancer.attach_to_network, + BoundLoadBalancer.detach_from_network, + BoundLoadBalancer.disable_public_interface, + BoundLoadBalancer.enable_public_interface, + BoundLoadBalancer.get_metrics, + ] + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers -class TestBoundLoadBalancer(object): @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"] ) @@ -33,97 +60,348 @@ 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 + +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 len(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" - @pytest.mark.parametrize("params", [{}]) - def test_get_actions( - self, hetzner_client, bound_load_balancer, response_get_actions, params + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + @pytest.mark.parametrize( + "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, ): - 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(actions) == 1 - assert isinstance(actions[0], BoundAction) - assert actions[0].id == 13 - assert actions[0].command == "change_protection" + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancer = resource_client.get_by_name("Web Frontend") + params = {"name": "Web Frontend"} + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_create_load_balancer, + ): + request_mock.return_value = response_create_load_balancer + + response = resource_client.create( + "my-balancer", + load_balancer_type=LoadBalancerType(name="lb11"), + location=Location(id=1), + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers", + json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + ) + + bound_load_balancer = response.load_balancer + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 1 + assert bound_load_balancer.name == "my-balancer" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb11") + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb11"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type(load_balancer, LoadBalancerType(id=1)) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": 1}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) def test_update( - self, 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_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", + ) + + 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", + "end": "2023-12-14T16:55:32+01:00", + }, + ) + 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 + request_mock.return_value = response_add_service + service = LoadBalancerService(listen_port=80, protocol="http") - action = bound_load_balancer.add_service(service) - hetzner_client.request.assert_called_with( - json={"protocol": "http", "listen_port": 80}, - url="/load_balancers/14/actions/add_service", + 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}, ) 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", [ @@ -131,7 +409,7 @@ def test_delete_service( LoadBalancerTarget( type="server", server=Server(id=1), use_private_ip=True ), - {"server": {"id": 1}}, + {"server": {"id": 1}, "use_private_ip": True}, ), ( LoadBalancerTarget(type="ip", ip=LoadBalancerTargetIP(ip="127.0.0.1")), @@ -147,19 +425,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) - params.update({"type": target.type, "use_private_ip": target.use_private_ip}) - hetzner_client.request.assert_called_with( - json=params, url="/load_balancers/14/actions/add_target", method="POST" + request_mock.return_value = response_add_target + + action = resource_client.add_target(load_balancer, target) + params.update({"type": target.type}) + + 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", [ @@ -184,34 +476,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( - json=params, url="/load_balancers/14/actions/remove_target", method="POST" + + 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": { @@ -222,294 +531,209 @@ 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(object): - @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}) - - load_balancers_client._client.request.assert_called_with( - url="/load_balancers", method="GET", params=params - ) - - assert len(bound_load_balancers) == 2 - - bound_load_balancer1 = bound_load_balancers[0] - bound_load_balancer2 = bound_load_balancers[1] - - assert bound_load_balancer1._client is load_balancers_client - assert bound_load_balancer1.id == 4711 - assert bound_load_balancer1.name == "Web Frontend" - - assert bound_load_balancer2._client is load_balancers_client - assert bound_load_balancer2.id == 4712 - assert bound_load_balancer2.name == "Web Frontend2" - - def test_get_by_name(self, 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"} + request_mock.return_value = response_detach_from_network - 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" + action = resource_client.detach_from_network(load_balancer, Network(id=1)) - 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"}, - ) - - assert action.id == 1 - assert action.progress == 0 + request_mock.return_value = action_response - @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 diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py index ecd0ecdd..47b081f2 100644 --- a/tests/unit/load_balancers/test_domain.py +++ b/tests/unit/load_balancers/test_domain.py @@ -1,12 +1,91 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone +from unittest import mock + +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 -from hcloud.load_balancers.domain import LoadBalancer +@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(object): + +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=tzoffset(None, 0) + 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/conftest.py b/tests/unit/locations/conftest.py index 1ed03ea3..c7643ea4 100644 --- a/tests/unit/locations/conftest.py +++ b/tests/unit/locations/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index 8b4d5abd..61150c42 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -1,19 +1,31 @@ +from __future__ import annotations + +from unittest import mock + import pytest # noqa: F401 -import mock # noqa: F401 -from hcloud.locations.client import LocationsClient +from hcloud import Client +from hcloud.locations import LocationsClient -class TestLocationsClient(object): +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 @@ -23,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 @@ -49,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 @@ -74,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/conftest.py b/tests/unit/networks/conftest.py index 109e3b5c..69287d21 100644 --- a/tests/unit/networks/conftest.py +++ b/tests/unit/networks/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -24,6 +26,7 @@ def network_response(): }, ], "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, "servers": [42], "protection": {"delete": False}, "labels": {}, @@ -55,6 +58,7 @@ def two_networks_response(): }, ], "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, "servers": [42], "protection": {"delete": False}, "labels": {}, @@ -73,6 +77,7 @@ def two_networks_response(): } ], "routes": [{"destination": "12.100.1.0/24", "gateway": "12.0.1.1"}], + "expose_routes_to_vswitch": False, "servers": [45], "protection": {"delete": False}, "labels": {}, @@ -105,6 +110,7 @@ def one_network_response(): }, ], "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, "servers": [42], "protection": {"delete": False}, "labels": {}, @@ -129,6 +135,32 @@ def network_create_response(): } ], "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, + "servers": [42], + "protection": {"delete": False}, + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + } + } + + +@pytest.fixture() +def network_create_response_with_expose_routes_to_vswitch(): + return { + "network": { + "id": 4711, + "name": "mynet", + "ip_range": "10.0.0.0/16", + "subnets": [ + { + "type": "cloud", + "ip_range": "10.0.1.0/24", + "network_zone": "eu-central", + "gateway": "10.0.0.1", + } + ], + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": True, "servers": [42], "protection": {"delete": False}, "labels": {}, @@ -153,6 +185,7 @@ def response_update_network(): } ], "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": True, "servers": [42], "protection": {"delete": False}, "labels": {}, diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py index 03249264..3f162b46 100644 --- a/tests/unit/networks/test_client.py +++ b/tests/unit/networks/test_client.py @@ -1,19 +1,44 @@ +from __future__ import annotations + +from unittest import mock + import pytest from dateutil.parser import isoparse -import mock -from hcloud.actions.client import BoundAction -from hcloud.networks.client import BoundNetwork, NetworksClient -from hcloud.networks.domain import NetworkSubnet, NetworkRoute, Network -from hcloud.servers.client import BoundServer +from hcloud import Client +from hcloud.networks import ( + BoundNetwork, + Network, + NetworkRoute, + NetworksClient, + NetworkSubnet, +) +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(object): @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"] ) @@ -41,126 +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].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(object): +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): @@ -183,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 @@ -197,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 @@ -221,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 @@ -244,42 +181,83 @@ 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( + + 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, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_create_response_with_expose_routes_to_vswitch, + ): + 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 + ) + + request_mock.assert_called_with( method="POST", + url="/networks", json={ "name": "mynet", "ip_range": "10.0.0.0/8", + "expose_routes_to_vswitch": True, }, ) 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", @@ -293,41 +271,109 @@ def test_create_with_subnet( }, ) - def test_create_with_route( - self, networks_client, network_route, network_create_response + def test_create_with_subnet_vswitch( + 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", routes=[network_route] + 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", - "routes": [ + "subnets": [ { - "destination": "10.100.1.0/24", - "gateway": "10.0.1.1", + "type": NetworkSubnet.TYPE_VSWITCH, + "ip_range": "10.0.1.0/24", + "network_zone": "eu-central", + "vswitch_id": 1000, } ], }, ) + def test_create_with_route( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_route, + network_create_response, + ): + request_mock.return_value = network_create_response + + networks_client.create( + name="mynet", ip_range="10.0.0.0/8", routes=[network_route] + ) + + request_mock.assert_called_with( + method="POST", + url="/networks", + json={ + "name": "mynet", + "ip_range": "10.0.0.0/8", + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + }, + ) + + 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, + ): + request_mock.return_value = ( + network_create_response_with_expose_routes_to_vswitch + ) + networks_client.create( + name="mynet", + ip_range="10.0.0.0/8", + routes=[network_route], + expose_routes_to_vswitch=True, + ) + + request_mock.assert_called_with( + method="POST", + url="/networks", + json={ + "name": "mynet", + "ip_range": "10.0.0.0/8", + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": True, + }, + ) + 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", @@ -338,55 +384,53 @@ def test_create_with_route_and_subnet( "network_zone": "eu-central", } ], - "routes": [ - { - "destination": "10.100.1.0/24", - "gateway": "10.0.1.1", - } - ], + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], }, ) @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) + def test_update( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + response_update_network, + ): + request_mock.return_value = response_update_network - assert actions[0]._client == networks_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "add_subnet" + network = networks_client.update( + network, name="new-name", expose_routes_to_vswitch=True + ) - @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") - networks_client._client.request.assert_called_with( - url="/networks/1", method="PUT", json={"name": "new-name"} + request_mock.assert_called_with( + method="PUT", + url="/networks/1", + json={"name": "new-name", "expose_routes_to_vswitch": True}, ) assert network.id == 4711 assert network.name == "new-name" + assert network.expose_routes_to_vswitch is True @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}, ) @@ -396,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 @@ -408,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", @@ -429,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", @@ -452,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"}, ) @@ -469,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"}, ) @@ -486,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"}, ) @@ -503,12 +590,20 @@ 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"}, ) diff --git a/tests/unit/networks/test_domain.py b/tests/unit/networks/test_domain.py index 84c71d31..dc3d3ba7 100644 --- a/tests/unit/networks/test_domain.py +++ b/tests/unit/networks/test_domain.py @@ -1,12 +1,28 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.networks import Network, NetworkRoute, NetworkSubnet + -from hcloud.networks.domain import Network +@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(object): +class TestNetwork: def test_created_is_datetime(self): network = Network(id=1, created="2016-01-30T23:50+00:00") assert network.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/placement_groups/conftest.py b/tests/unit/placement_groups/conftest.py index bf82cdc3..37f532a4 100644 --- a/tests/unit/placement_groups/conftest.py +++ b/tests/unit/placement_groups/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py index 674ce328..fc8cada8 100644 --- a/tests/unit/placement_groups/test_client.py +++ b/tests/unit/placement_groups/test_client.py @@ -1,10 +1,19 @@ +from __future__ import annotations + +from unittest import mock + import pytest -import mock -from hcloud.placement_groups.client 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"] @@ -12,14 +21,21 @@ def check_variables(placement_group, expected): assert placement_group.type == expected["type"] -class TestBoundPlacementGroup(object): +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 - def test_bound_placement_group_init(self, placement_group_response): + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) + + def test_init(self, placement_group_response): bound_placement_group = BoundPlacementGroup( client=mock.MagicMock(), data=placement_group_response["placement_group"] ) @@ -28,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 resource_client(self, client: Client): + return client.placement_groups -class TestPlacementGroupsClient(object): @pytest.fixture() - def placement_groups_client(self): - return PlacementGroupsClient(client=mock.MagicMock()) + 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"]) @@ -94,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"] @@ -114,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) @@ -130,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( @@ -149,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"], @@ -185,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 f6d040b6..0d25b54f 100644 --- a/tests/unit/placement_groups/test_domain.py +++ b/tests/unit/placement_groups/test_domain.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.placement_groups import PlacementGroup + -from hcloud.placement_groups.domain import PlacementGroup +@pytest.mark.parametrize( + "value", + [ + (PlacementGroup(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestPlacementGroup(object): +class TestPlacementGroup: def test_created_is_datetime(self): placement_group = PlacementGroup(id=1, created="2016-01-30T23:50+00:00") assert placement_group.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) diff --git a/tests/unit/primary_ips/__init__.py b/tests/unit/primary_ips/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/primary_ips/conftest.py b/tests/unit/primary_ips/conftest.py new file mode 100644 index 00000000..4a7b23c6 --- /dev/null +++ b/tests/unit/primary_ips/conftest.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def primary_ip_response(): + 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, + } + }, + "primary_ips": [ + { + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "created": "2016-01-30T23:55:00+00:00", + "datacenter": { + "description": "Falkenstein DC Park 8", + "id": 42, + "location": { + "city": "Falkenstein", + "country": "DE", + "description": "Falkenstein DC Park 1", + "id": 1, + "latitude": 50.47612, + "longitude": 12.370071, + "name": "fsn1", + "network_zone": "eu-central", + }, + "name": "fsn1-dc8", + "server_types": { + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + "supported": [1, 2, 3], + }, + }, + "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], + "id": 42, + "ip": "131.232.99.1", + "labels": {}, + "name": "my-resource", + "protection": {"delete": False}, + "type": "ipv4", + } + ], + } + + +@pytest.fixture() +def all_primary_ips_response(): + return { + "meta": { + "pagination": { + "last_page": 1, + "next_page": None, + "page": 1, + "per_page": 25, + "previous_page": None, + "total_entries": 1, + } + }, + "primary_ips": [ + { + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "created": "2016-01-30T23:55:00+00:00", + "datacenter": { + "description": "Falkenstein DC Park 8", + "id": 42, + "location": { + "city": "Falkenstein", + "country": "DE", + "description": "Falkenstein DC Park 1", + "id": 1, + "latitude": 50.47612, + "longitude": 12.370071, + "name": "fsn1", + "network_zone": "eu-central", + }, + "name": "fsn1-dc8", + "server_types": { + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + "supported": [1, 2, 3], + }, + }, + "dns_ptr": [{"dns_ptr": "server.example.com", "ip": "131.232.99.1"}], + "id": 42, + "ip": "131.232.99.1", + "labels": {}, + "name": "my-resource", + "protection": {"delete": False}, + "type": "ipv4", + } + ], + } + + +@pytest.fixture() +def 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"}, + } + ] + } diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py new file mode 100644 index 00000000..a4ffcc3a --- /dev/null +++ b/tests/unit/primary_ips/test_client.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.datacenters import BoundDatacenter, Datacenter +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient + +from ..conftest import BoundModelTestCase + + +class TestBoundPrimaryIP(BoundModelTestCase): + methods = [ + BoundPrimaryIP.update, + BoundPrimaryIP.delete, + BoundPrimaryIP.change_dns_ptr, + BoundPrimaryIP.change_protection, + BoundPrimaryIP.assign, + BoundPrimaryIP.unassign, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.primary_ips + + @pytest.fixture() + def bound_model(self, resource_client: PrimaryIPsClient): + return BoundPrimaryIP(resource_client, data=dict(id=14)) + + def test_init(self, primary_ip_response): + bound_primary_ip = BoundPrimaryIP( + client=mock.MagicMock(), data=primary_ip_response["primary_ip"] + ) + + assert bound_primary_ip.id == 42 + assert bound_primary_ip.name == "my-resource" + assert bound_primary_ip.ip == "131.232.99.1" + assert bound_primary_ip.type == "ipv4" + assert bound_primary_ip.protection == {"delete": False} + assert bound_primary_ip.labels == {} + assert bound_primary_ip.blocked is False + + assert bound_primary_ip.assignee_id == 17 + assert bound_primary_ip.assignee_type == "server" + + with pytest.deprecated_call(): + datacenter = bound_primary_ip.datacenter + + assert isinstance(datacenter, BoundDatacenter) + assert datacenter.id == 42 + assert datacenter.name == "fsn1-dc8" + assert datacenter.description == "Falkenstein DC Park 8" + assert datacenter.location.country == "DE" + assert datacenter.location.city == "Falkenstein" + assert datacenter.location.latitude == 50.47612 + assert datacenter.location.longitude == 12.370071 + + +class TestPrimaryIPsClient: + @pytest.fixture() + def primary_ips_client(self, client: Client): + return PrimaryIPsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_response, + ): + request_mock.return_value = primary_ip_response + + bound_primary_ip = primary_ips_client.get_by_id(1) + + 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, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + one_primary_ips_response, + ): + request_mock.return_value = one_primary_ips_response + + bound_primary_ip = primary_ips_client.get_by_name("my-resource") + + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params={"name": "my-resource"}, + ) + assert bound_primary_ip._client is primary_ips_client + assert bound_primary_ip.id == 42 + assert bound_primary_ip.name == "my-resource" + + @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) + def test_get_all( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + all_primary_ips_response, + params, + ): + request_mock.return_value = all_primary_ips_response + + bound_primary_ips = primary_ips_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params=params, + ) + + assert len(bound_primary_ips) == 1 + + 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_datacenter( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_response, + ): + request_mock.return_value = primary_ip_response + + with pytest.deprecated_call(): + response = primary_ips_client.create( + type="ipv6", + name="my-resource", + datacenter=Datacenter(name="datacenter"), + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "my-resource", + "type": "ipv6", + "assignee_type": "server", + "datacenter": "datacenter", + "auto_delete": False, + }, + ) + + bound_primary_ip = response.primary_ip + action = response.action + + assert bound_primary_ip._client is primary_ips_client + assert bound_primary_ip.id == 42 + assert bound_primary_ip.name == "my-resource" + assert action is None + + def test_create_with_assignee_id( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip_create_response, + ): + request_mock.return_value = primary_ip_create_response + + response = primary_ips_client.create( + type="ipv6", + name="my-ip", + assignee_id=17, + assignee_type="server", + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "my-ip", + "type": "ipv6", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": False, + }, + ) + bound_primary_ip = response.primary_ip + action = response.action + + assert bound_primary_ip._client is primary_ips_client + assert bound_primary_ip.id == 42 + assert bound_primary_ip.name == "my-ip" + assert bound_primary_ip.assignee_id == 17 + assert action.id == 13 + + @pytest.mark.parametrize( + "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + ) + def test_update( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + response_update_primary_ip, + ): + request_mock.return_value = response_update_primary_ip + + primary_ip = primary_ips_client.update( + primary_ip, auto_delete=True, name="my-resource" + ) + + request_mock.assert_called_with( + method="PUT", + url="/primary_ips/1", + json={"auto_delete": True, "name": "my-resource"}, + ) + + assert primary_ip.id == 42 + assert primary_ip.auto_delete is True + assert primary_ip.name == "my-resource" + + @pytest.mark.parametrize( + "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + action = primary_ips_client.change_protection(primary_ip, True) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/1/actions/change_protection", + json={"delete": True}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "primary_ip", [PrimaryIP(id=1), BoundPrimaryIP(mock.MagicMock(), dict(id=1))] + ) + def test_delete( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + delete_success = primary_ips_client.delete(primary_ip) + + request_mock.assert_called_with( + method="DELETE", + url="/primary_ips/1", + ) + + assert delete_success is True + + @pytest.mark.parametrize( + "assignee_id,assignee_type,primary_ip", + [ + (1, "server", PrimaryIP(id=12)), + (1, "server", BoundPrimaryIP(mock.MagicMock(), dict(id=12))), + ], + ) + def test_assign( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + assignee_id, + assignee_type, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + action = primary_ips_client.assign(primary_ip, assignee_id, assignee_type) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/12/actions/assign", + json={"assignee_id": 1, "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, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + action = primary_ips_client.unassign(primary_ip) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/12/actions/unassign", + ) + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "primary_ip", [PrimaryIP(id=12), BoundPrimaryIP(mock.MagicMock(), dict(id=12))] + ) + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + primary_ips_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + action = primary_ips_client.change_dns_ptr( + primary_ip, "1.2.3.4", "server02.example.com" + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/12/actions/change_dns_ptr", + json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, + ) + assert action.id == 1 + assert action.progress == 0 diff --git a/tests/unit/primary_ips/test_domain.py b/tests/unit/primary_ips/test_domain.py new file mode 100644 index 00000000..c1fadf0a --- /dev/null +++ b/tests/unit/primary_ips/test_domain.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +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): + 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 dd2fbb2d..5ff2b5e5 100644 --- a/tests/unit/server_types/conftest.py +++ b/tests/unit/server_types/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -8,6 +10,7 @@ def server_type_response(): "id": 1, "name": "cx11", "description": "CX11", + "category": "Shared vCPU", "cores": 1, "memory": 1, "disk": 25, @@ -26,6 +29,28 @@ def server_type_response(): ], "storage_type": "local", "cpu_type": "shared", + "architecture": "x86", + "included_traffic": 21990232555520, + "deprecated": True, + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + "locations": [ + { + "id": 1, + "name": "nbg1", + "deprecation": None, + }, + { + "id": 2, + "name": "fsn1", + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + }, + ], } } @@ -56,6 +81,13 @@ def two_server_types_response(): ], "storage_type": "local", "cpu_type": "shared", + "architecture": "x86", + "included_traffic": 21990232555520, + "deprecated": True, + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, }, { "id": 2, @@ -90,6 +122,10 @@ def two_server_types_response(): ], "storage_type": "local", "cpu_type": "shared", + "architecture": "x86", + "included_traffic": 21990232555520, + "deprecated": False, + "deprecation": None, }, ] } @@ -121,6 +157,13 @@ def one_server_types_response(): ], "storage_type": "local", "cpu_type": "shared", + "architecture": "x86", + "included_traffic": 21990232555520, + "deprecated": True, + "deprecation": { + "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 a12a3e8f..cdba00f1 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -1,20 +1,77 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from unittest import mock + import pytest -import mock +from hcloud import Client +from hcloud.server_types import BoundServerType, ServerTypesClient + + +class TestBoundServerType: + @pytest.fixture() + def bound_server_type(self, client: Client): + return BoundServerType(client.server_types, data=dict(id=14)) + + def test_init(self, server_type_response): + o = BoundServerType( + client=mock.MagicMock(), data=server_type_response["server_type"] + ) + + assert o.id == 1 + assert o.name == "cx11" + assert o.description == "CX11" + assert o.category == "Shared vCPU" + assert o.cores == 1 + assert o.memory == 1 + assert o.disk == 25 + assert o.storage_type == "local" + assert o.cpu_type == "shared" + assert o.architecture == "x86" + assert len(o.locations) == 2 + assert o.locations[0].location.id == 1 + assert o.locations[0].location.name == "nbg1" + assert o.locations[0].deprecation is None + assert o.locations[1].location.id == 2 + assert o.locations[1].location.name == "fsn1" + assert ( + o.locations[1].deprecation.announced.isoformat() + == "2023-06-01T00:00:00+00:00" + ) + assert ( + o.locations[1].deprecation.unavailable_after.isoformat() + == "2023-09-01T00:00:00+00:00" + ) -from hcloud.server_types.client import ServerTypesClient + 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(object): +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 @@ -23,15 +80,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 @@ -47,14 +114,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 @@ -70,14 +146,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 cbb992d8..01649320 100644 --- a/tests/unit/servers/conftest.py +++ b/tests/unit/servers/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -12,12 +14,14 @@ def response_simple_server(): "public_net": { "ipv4": { "ip": "1.2.3.4", + "id": 1, "blocked": False, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 2, "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, "floating_ips": [478], @@ -119,11 +123,13 @@ def response_create_simple_server(): "ipv4": { "ip": "1.2.3.4", "blocked": False, + "id": 1, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 2, "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, "floating_ips": [], @@ -190,9 +196,7 @@ def response_create_simple_server(): "deprecated": "2018-02-28T00:00:00+00:00", "labels": {}, }, - "iso": { - "id": 4711, - }, + "iso": {"id": 4711}, "rescue_enabled": False, "locked": False, "backup_window": "22-02", @@ -241,11 +245,13 @@ def response_update_server(): "ipv4": { "ip": "1.2.3.4", "blocked": False, + "id": 1, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 2, "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, "floating_ips": [478], @@ -332,6 +338,54 @@ def response_update_server(): } +@pytest.fixture() +def response_get_metrics(): + return { + "metrics": { + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + "step": 3.0, + "time_series": { + "cpu": { + "values": [ + [1702572594, "0.3746000025854892"], + [1702572597, "0.35842215349409734"], + [1702572600, "0.7381525488039541"], + ] + }, + "disk.0.iops.read": { + "values": [ + [1702572594, "0"], + [1702572597, "0"], + [1702572600, "0"], + ] + }, + "disk.0.bandwidth.read": { + "values": [ + [1702572594, "0"], + [1702572597, "0"], + [1702572600, "0"], + ] + }, + "disk.0.bandwidth.write": { + "values": [ + [1702572594, "24064"], + [1702572597, "2048"], + [1702572600, "0"], + ] + }, + "disk.0.iops.write": { + "values": [ + [1702572594, "4.875"], + [1702572597, "0.25"], + [1702572600, "0"], + ] + }, + }, + } + } + + @pytest.fixture() def response_simple_servers(): return { @@ -345,11 +399,13 @@ def response_simple_servers(): "ipv4": { "ip": "1.2.3.4", "blocked": False, + "id": 2, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 1, "dns_ptr": [ {"ip": "2001:db8::1", "dns_ptr": "server.example.com"} ], @@ -445,11 +501,13 @@ def response_simple_servers(): "ipv4": { "ip": "1.2.3.4", "blocked": False, + "id": 3, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 4, "dns_ptr": [ {"ip": "2001:db8::1", "dns_ptr": "server.example.com"} ], @@ -554,11 +612,13 @@ def response_full_server(): "ipv4": { "ip": "1.2.3.4", "blocked": False, + "id": 1, "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, + "id": 2, "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, "floating_ips": [478], diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index b92e9ff8..0a09544d 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -1,43 +1,75 @@ -import mock -import pytest +from __future__ import annotations -from hcloud.firewalls.client import BoundFirewall -from hcloud.firewalls.domain import Firewall -from hcloud.floating_ips.client import BoundFloatingIP -from hcloud.isos.client import BoundIso -from hcloud.servers.client import ServersClient, BoundServer +from unittest import mock -from hcloud.servers.domain import ( - Server, - PublicNetwork, +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 +from hcloud.isos import BoundIso, Iso +from hcloud.locations import Location +from hcloud.networks import BoundNetwork, Network +from hcloud.placement_groups import BoundPlacementGroup, PlacementGroup +from hcloud.server_types import BoundServerType, ServerType +from hcloud.servers import ( + BoundServer, IPv4Address, IPv6Network, - PublicNetworkFirewall, PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServersClient, ) -from hcloud.volumes.client import BoundVolume -from hcloud.volumes.domain import Volume -from hcloud.images.domain import Image -from hcloud.images.client import BoundImage -from hcloud.isos.domain import Iso -from hcloud.datacenters.client import BoundDatacenter -from hcloud.datacenters.domain import Datacenter -from hcloud.locations.domain import Location -from hcloud.actions.client import BoundAction -from hcloud.server_types.client import BoundServerType -from hcloud.server_types.domain import ServerType -from hcloud.networks.domain import Network -from hcloud.networks.client import BoundNetwork -from hcloud.placement_groups.domain import PlacementGroup -from hcloud.placement_groups.client import BoundPlacementGroup - - -class TestBoundServer(object): +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, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.servers + @pytest.fixture() - def bound_server(self, hetzner_client): - return BoundServer(client=hetzner_client.servers, data=dict(id=14)) + def bound_model(self, resource_client: ServersClient): + return BoundServer(resource_client, data=dict(id=14)) - def test_bound_server_init(self, response_full_server): + # pylint: disable=too-many-statements + def test_init(self, response_full_server): bound_server = BoundServer( client=mock.MagicMock(), data=response_full_server["server"] ) @@ -68,40 +100,41 @@ 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 + with pytest.deprecated_call(): + datacenter = bound_server.datacenter + + assert isinstance(datacenter, BoundDatacenter) + assert datacenter._client == bound_server._client._parent.datacenters + assert datacenter.id == 1 + assert datacenter.complete is True assert isinstance(bound_server.server_type, BoundServerType) assert ( bound_server.server_type._client - == 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 @@ -110,7 +143,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" @@ -120,396 +153,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].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].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_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 - action = bound_server.rebuild(Image(name="ubuntu-20.04")) - hetzner_client.request.assert_called_with( - url="/servers/14/actions/rebuild", - method="POST", - json={"image": "ubuntu-20.04"}, - ) - - assert action.id == 1 - assert action.progress == 0 - - 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, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_server, ): - 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" + request_mock.return_value = response_simple_server - def test_remove_from_placement_group( - self, hetzner_client, bound_server, response_remove_from_placement_group - ): - 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" - - -class TestServersClient(object): - @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 @@ -523,15 +191,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 @@ -549,14 +227,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 @@ -572,14 +259,22 @@ def test_get_all(self, servers_client, response_simple_servers, params): assert bound_server2.id == 2 assert bound_server2.name == "my-server2" - def test_get_by_name(self, servers_client, response_simple_servers): - servers_client._client.request.return_value = response_simple_servers + def test_get_by_name( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_servers, + ): + request_mock.return_value = response_simple_servers + bound_server = servers_client.get_by_name("my-server") params = {"name": "my-server"} - servers_client._client.request.assert_called_with( - url="/servers", method="GET", params=params + request_mock.assert_called_with( + method="GET", + url="/servers", + params=params, ) assert bound_server._client is servers_client @@ -587,18 +282,24 @@ def test_get_by_name(self, servers_client, response_simple_servers): assert bound_server.name == "my-server" def test_create_with_datacenter( - self, servers_client, response_create_simple_server + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, ): - servers_client._client.request.return_value = response_create_simple_server - 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( - url="/servers", + request_mock.return_value = response_create_simple_server + + with pytest.deprecated_call(): + response = servers_client.create( + "my-server", + server_type=ServerType(name="cx11"), + image=Image(id=4711), + datacenter=Datacenter(id=1), + ) + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -609,22 +310,35 @@ def test_create_with_datacenter( ) 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" - def test_create_with_location(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + assert isinstance(bound_action, BoundAction) + assert bound_action._client == servers_client._parent.actions + assert bound_action.id == 1 + assert bound_action.command == "create_server" + + def test_create_with_location( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server + response = servers_client.create( "my-server", server_type=ServerType(name="cx11"), image=Image(name="ubuntu-20.04"), location=Location(name="fsn1"), ) - servers_client._client.request.assert_called_with( - url="/servers", + + request_mock.assert_called_with( method="POST", + url="/servers", json={ "name": "my-server", "server_type": "cx11", @@ -642,12 +356,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", @@ -656,9 +376,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", @@ -680,14 +401,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", @@ -696,9 +423,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", @@ -720,14 +448,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", @@ -736,9 +470,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", @@ -760,16 +495,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", @@ -779,9 +518,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", @@ -803,7 +542,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" @@ -812,31 +551,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 @@ -845,11 +574,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 @@ -858,11 +596,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 @@ -871,11 +618,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 @@ -884,11 +640,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 @@ -897,11 +662,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 @@ -910,11 +684,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 @@ -924,12 +707,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 @@ -940,15 +730,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}, ) @@ -959,13 +755,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}, ) @@ -975,21 +777,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]}, ) @@ -1000,11 +816,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 @@ -1013,14 +838,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", @@ -1035,26 +868,48 @@ 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 - action = servers_client.rebuild(server, Image(name="ubuntu-20.04")) - servers_client._client.request.assert_called_with( - url="/servers/1/actions/rebuild", + 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, + ) + + request_mock.assert_called_with( method="POST", + url="/servers/1/actions/rebuild", json={"image": "ubuntu-20.04"}, ) - assert action.id == 1 - assert action.progress == 0 + assert response.action.id == 1 + assert response.action.progress == 0 + assert response.root_password is None or isinstance(response.root_password, str) @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 @@ -1063,11 +918,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 @@ -1076,14 +940,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"}, ) @@ -1093,11 +965,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 @@ -1106,12 +987,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"}, ) @@ -1121,12 +1010,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}, ) @@ -1137,12 +1034,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 @@ -1160,15 +1064,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", @@ -1187,13 +1098,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}, ) @@ -1208,18 +1126,106 @@ 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"]}, ) assert action.id == 1 assert action.progress == 0 assert action.command == "change_alias_ips" + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "placement_group", + [PlacementGroup(id=897), BoundPlacementGroup(mock.MagicMock, dict(id=897))], + ) + def test_add_to_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + placement_group, + response_add_to_placement_group, + ): + request_mock.return_value = response_add_to_placement_group + + action = servers_client.add_to_placement_group(server, placement_group) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/add_to_placement_group", + json={"placement_group": 897}, + ) + + assert action.id == 13 + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_remove_from_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_remove_from_placement_group, + ): + request_mock.return_value = response_remove_from_placement_group + + action = servers_client.remove_from_placement_group(server) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/remove_from_placement_group", + ) + + assert action.id == 13 + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_get_metrics( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + response_get_metrics, + ): + request_mock.return_value = response_get_metrics + + response = servers_client.get_metrics( + server, + type=["cpu", "disk"], + start="2023-12-14T17:40:00+01:00", + end="2023-12-14T17:50:00+01:00", + ) + + request_mock.assert_called_with( + method="GET", + url="/servers/1/metrics", + params={ + "type": "cpu,disk", + "start": "2023-12-14T17:40:00+01:00", + "end": "2023-12-14T17:50:00+01:00", + }, + ) + + assert "cpu" in response.metrics.time_series + assert "disk.0.iops.read" in response.metrics.time_series + assert len(response.metrics.time_series["disk.0.iops.read"]["values"]) == 3 diff --git a/tests/unit/servers/test_domain.py b/tests/unit/servers/test_domain.py index 9bd1b394..2a80380c 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -1,12 +1,95 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone +from unittest import mock + +import pytest + +from hcloud.networks import Network +from hcloud.servers import ( + BoundServer, + IPv4Address, + IPv6Network, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServerCreatePublicNetwork, +) -from hcloud.servers.domain import Server +@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(object): + +class TestServer: def test_created_is_datetime(self): server = Server(id=1, created="2016-01-30T23:50+00:00") assert server.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 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/conftest.py b/tests/unit/ssh_keys/conftest.py index 1b45c5f3..b465b241 100644 --- a/tests/unit/ssh_keys/conftest.py +++ b/tests/unit/ssh_keys/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index 6562304f..bed07b54 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -1,16 +1,30 @@ +from __future__ import annotations + +from unittest import mock + import pytest -import mock -from hcloud.ssh_keys.client import SSHKeysClient, BoundSSHKey -from hcloud.ssh_keys.domain import SSHKey +from hcloud import Client +from hcloud.ssh_keys import BoundSSHKey, SSHKey, SSHKeysClient + +from ..conftest import BoundModelTestCase -class TestBoundSSHKey(object): +class TestBoundSSHKey(BoundModelTestCase): + methods = [ + BoundSSHKey.update, + BoundSSHKey.delete, + ] + @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"] ) @@ -23,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(object): +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 @@ -70,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 @@ -94,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 @@ -116,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"}, ) @@ -161,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 @@ -174,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 c19a37e6..9521f3a5 100644 --- a/tests/unit/ssh_keys/test_domain.py +++ b/tests/unit/ssh_keys/test_domain.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.ssh_keys import SSHKey + -from hcloud.ssh_keys.domain import SSHKey +@pytest.mark.parametrize( + "value", + [ + (SSHKey(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestSSHKey(object): +class TestSSHKey: def test_created_is_datetime(self): - sshKey = SSHKey(id=1, created="2016-01-30T23:50+00:00") - assert sshKey.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 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 new file mode 100644 index 00000000..41094a88 --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +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, + constant_backoff_function, + exponential_backoff_function, +) +from hcloud._client import ClientBase, _build_user_agent + + +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): + return Client(token="TOKEN") + + 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") + + +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 client(self): + client = ClientBase( + token="TOKEN", + endpoint="https://api.hetzner.cloud/v1", + ) + client._session = mock.MagicMock() + return client + + 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 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 + + @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=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"}, + ) + + result = client.request( + method="POST", + url="/path", + params={"argument": "value"}, + timeout=2, + ) + + client._session.request.assert_called_once_with( + method="POST", + url="https://api.hetzner.cloud/v1/path", + headers={ + "User-Agent": "hcloud-python/0.0.0", + "Authorization": "Bearer TOKEN", + "Accept": "application/json", + }, + params={"argument": "value"}, + timeout=2, + ) + assert result == {"result": "data"} + + def test_request_200_empty_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + text="", + ) + + 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'", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 200 + assert exc.value.message == "OK" + assert exc.value.details["content"] == b"{'key': 'value'" + + def test_request_fail_422(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "invalid_input", + "message": "invalid input in field 'broken_field': is too long", + "details": { + "fields": [ + {"name": "broken_field", "messages": ["is too long"]} + ] + }, + } + }, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "invalid_input" + assert exc.value.message == "invalid input in field 'broken_field': is too long" + assert exc.value.details["fields"][0]["name"] == "broken_field" + + def test_request_fail_422_correlation_id(self, client: ClientBase): + response = make_response( + status=HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "error": { + "code": "service_error", + "message": "Something crashed", + } + }, + ) + response.headers["X-Correlation-Id"] = "67ed842dc8bc8673" + client._session.request.return_value = response + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == "service_error" + assert exc.value.message == "Something crashed" + assert exc.value.details is None + assert exc.value.correlation_id == "67ed842dc8bc8673" + assert str(exc.value) == "Something crashed (service_error, 67ed842dc8bc8673)" + + def test_request_fail_500(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + text="Internal Server Error", + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] == b"Internal Server Error" + + def test_request_fail_500_no_content(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert exc.value.code == 500 + assert exc.value.message == "Internal Server Error" + assert exc.value.details["content"] is None + assert str(exc.value) == "Internal Server Error (500)" + + def test_request_fail_419(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.return_value = make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ) + with pytest.raises(APIException) as exc: + client.request(method="POST", url="/path") + + assert client._session.request.call_count == 6 + assert exc.value.code == "rate_limit_exceeded" + assert exc.value.message == "limit of 3600 requests per hour reached" + + def test_request_fail_419_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + make_response( + status=HTTPStatus.TOO_MANY_REQUESTS, + json={ + "error": { + "code": "rate_limit_exceeded", + "message": "limit of 3600 requests per hour reached", + "details": None, + } + }, + ), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} + + def test_request_fail_timeout(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + client._session.request.side_effect = requests.exceptions.Timeout("timeout") + + with pytest.raises(requests.exceptions.Timeout) as exc: + client.request(method="GET", url="/path") + + assert str(exc.value) == "timeout" + assert client._session.request.call_count == 6 + + def test_request_fail_timeout_recover(self, client: ClientBase): + client._retry_interval_func = constant_backoff_function(0.0) + + client._session.request.side_effect = [ + requests.exceptions.Timeout("timeout"), + make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ), + ] + + result = client.request(method="GET", url="/path") + + assert client._session.request.call_count == 2 + assert result == {"result": "data"} diff --git a/tests/unit/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 c680ec1d..00000000 --- a/tests/unit/test_hcloud.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import json -from unittest.mock import MagicMock - -import requests -import pytest -from hcloud import Client, APIException - - -class TestHetznerClient(object): - @pytest.fixture() - def client(self): - Client._version = "0.0.0" - client = Client(token="project_token") - - client._requests_session = MagicMock() - return client - - @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 - - @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", - ) - user_agent = client._get_user_agent() - assert user_agent == "my-app/1.0.0 hcloud-python/0.0.0" - - def test__get_headers(self, client): - headers = client._get_headers() - assert headers == { - "User-Agent": "hcloud-python/0.0.0", - "Authorization": "Bearer project_token", - } - - def test_request_library_mocked(self, client): - response = client.request("POST", "url", params={"1": 2}) - assert response.__class__.__name__ == "MagicMock" - - def test_request_ok(self, client, response): - client._requests_session.request.return_value = response - response = client.request( - "POST", "/servers", params={"argument": "value"}, timeout=2 - ) - client._requests_session.request.assert_called_once() - assert client._requests_session.request.call_args[0] == ( - "POST", - "https://api.hetzner.cloud/v1/servers", - ) - assert client._requests_session.request.call_args[1]["params"] == { - "argument": "value" - } - assert client._requests_session.request.call_args[1]["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 = "{'key': 'value'".encode("utf-8") - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 200 - assert error.message == "OK" - assert error.details["content"] == content - - def test_request_empty_content_200(self, client, response): - content = "" - response.reason = "OK" - response._content = content - client._requests_session.request.return_value = response - response = client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - assert response == "" - - def test_request_500_empty_content(self, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "" - client._requests_session.request.return_value = fail_response - with pytest.raises(APIException) as exception_info: - client.request( - "POST", "http://url.com", params={"argument": "value"}, timeout=2 - ) - error = exception_info.value - assert error.code == 500 - assert error.message == "Internal Server Error" - assert error.details["content"] == "" - assert str(error) == "Internal Server Error" - - 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 - ) - assert client._requests_session.request.call_count == 2 diff --git a/tests/unit/volumes/conftest.py b/tests/unit/volumes/conftest.py index e4a52542..936ac6fa 100644 --- a/tests/unit/volumes/conftest.py +++ b/tests/unit/volumes/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index a075ad5d..dd86f22b 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -1,20 +1,35 @@ +from __future__ import annotations + +from unittest import mock + import pytest from dateutil.parser import isoparse -import mock -from hcloud.actions.client import BoundAction -from hcloud.servers.client import BoundServer -from hcloud.servers.domain import Server -from hcloud.volumes.client import VolumesClient, BoundVolume -from hcloud.volumes.domain import Volume -from hcloud.locations.client import BoundLocation -from hcloud.locations.domain import Location +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, + ] -class TestBoundVolume(object): @pytest.fixture() - def bound_volume(self, hetzner_client): - return BoundVolume(client=hetzner_client.volumes, data=dict(id=14)) + def resource_client(self, client: Client): + return client.volumes + + @pytest.fixture() + 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,106 +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].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(object): +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 @@ -150,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 @@ -174,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 @@ -197,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", @@ -220,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, @@ -246,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, @@ -263,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 @@ -318,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}, ) @@ -333,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 @@ -352,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 @@ -364,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 @@ -376,11 +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 diff --git a/tests/unit/volumes/test_domain.py b/tests/unit/volumes/test_domain.py index 63886281..50d54d43 100644 --- a/tests/unit/volumes/test_domain.py +++ b/tests/unit/volumes/test_domain.py @@ -1,12 +1,26 @@ +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone + +import pytest + +from hcloud.volumes import Volume + -from hcloud.volumes.domain import Volume +@pytest.mark.parametrize( + "value", + [ + (Volume(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) -class TestVolume(object): +class TestVolume: def test_created_is_datetime(self): volume = Volume(id=1, created="2016-01-30T23:50+00:00") assert volume.created == datetime.datetime( - 2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0) + 2016, 1, 30, 23, 50, tzinfo=timezone.utc ) 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 d3d8780b..813895b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,27 +1,17 @@ [tox] -envlist = py36, py37, py38, py39, py310, flake8 - -[testenv:flake8] -basepython = python -deps = flake8==3.6.0 -commands = flake8 hcloud tests setup.py - -[testenv:black] -basepython = python -deps = black==21.7b0 -commands = black . --check --diff +envlist = py310, py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT deps = - -r{toxinidir}/requirements/test.txt + -e.[test] commands = pytest tests/unit {posargs} [gh-actions] python = - 3.6: py36 - 3.7: py37 - 3.8: py38 - 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314