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/links.yml b/.github/workflows/links.yml new file mode 100644 index 00000000..2791863e --- /dev/null +++ b/.github/workflows/links.yml @@ -0,0 +1,41 @@ +name: Links + +on: + pull_request: + workflow_dispatch: + schedule: + - cron: "43 12 * * *" + +jobs: + check: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: .lycheecache + key: cache-lychee-${{ github.sha }} + restore-keys: cache-lychee- + + - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 + with: + fail: true + args: > + --root-dir ${{ github.workspace }} + --verbose + --no-progress + --cache + --max-cache-age 2d + --include-fragments + --exclude 'api.hetzner.cloud' + --exclude 'api.hetzner.com' + --exclude 'https://docs.hetzner.cloud/reference/cloud#' + --exclude 'https://docs.hetzner.cloud/reference/hetzner#' + --exclude 'codecov.io' + --exclude 'github.com' + --exclude 'https://docs.hetzner.com/networking/dns/faq/beta/' + --exclude 'https://docs.hetzner.com/networking/dns/migration-to-hetzner-console/process/' + '**/*.md' + '**/*.py' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..f1910d8d --- /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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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 new file mode 100644 index 00000000..08d3de84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + branches: [main] + pull_request: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-packages + path: dist/ + + - name: Publish packages to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/releaser-pleaser.yml b/.github/workflows/releaser-pleaser.yml new file mode 100644 index 00000000..e810632f --- /dev/null +++ b/.github/workflows/releaser-pleaser.yml @@ -0,0 +1,29 @@ +name: Releaser-pleaser + +on: + push: + branches: [main] + pull_request_target: + types: + - edited + - labeled + - unlabeled + +concurrency: + group: releaser-pleaser + cancel-in-progress: true + +jobs: + releaser-pleaser: + # Do not run on forks. + if: github.repository == 'hetznercloud/hcloud-python' + + runs-on: ubuntu-latest + steps: + - name: releaser-pleaser + uses: apricote/releaser-pleaser@93a3e2c401320206dcd9a9b7a5bbc0850072b3c6 # v0.9.0 + with: + token: ${{ secrets.HCLOUD_BOT_TOKEN }} + extra-files: | + setup.py + hcloud/_version.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..90181486 --- /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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ca13599f..f2cc23b9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,69 +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 - stage: test - variables: - FAKE_API_ENDPOINT: "http://api_mock:8080" - services: - - name: "lkdevelopment/hetzner-cloud-api-mock" - alias: "api_mock" - script: - tox - tags: - - fast +stages: + - test -python27: - <<: *tests_template - image: python:2.7 - script: tox -e py27 +pre-commit: + extends: [.pre-commit] -python34: - <<: *tests_template - image: python:3.4 - script: tox -e py34 +lint: + stage: test -python35: - <<: *tests_template - image: python:3.5 - script: tox -e py35 + image: python:3.14-alpine + before_script: + - apk add make bash + - make venv + script: + - make lint -python36: - <<: *tests_template - image: python:3.6 - script: tox -e py36 +test: + stage: test -python37: - <<: *tests_template - image: python:3.7 - script: tox -e py37 + parallel: + matrix: + - python_version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "3.14" -test-style: + image: python:${python_version}-alpine before_script: - - pip install tox - image: python:3.6 - script: tox -e flake8 - tags: - - fast - -pages: + - apk add make + - pip install tox script: - - apk --no-cache add make - - pip install arrow - - pip install sphinx - - pip install sphinx_rtd_theme - - pip install recommonmark - - rm -f docs/hcloud.rst - - rm -f docs/modules.rst - - sphinx-apidoc -o docs/ hcloud - - make -C docs clean - - make -C docs html - - mv docs/_build/html/ public - artifacts: - paths: - - public - image: python:3.6-alpine - tags: - - fast + - tox -e ${python_version} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..c35de9e0 --- /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/jooola/pre-commit-prettier + rev: 3.9.6 + 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: 8.0.1 + hooks: + - id: isort + + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 26.5.1 + 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..e34d455d --- /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-24.04 + tools: + python: "3.14" + +# 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/.travis.yml b/.travis.yml deleted file mode 100644 index 1d73f171..00000000 --- a/.travis.yml +++ /dev/null @@ -1,46 +0,0 @@ -language: python -dist: xenial -sudo: true -stages: - - test - - codingstyle - - name: deploy - if: tag IS present -python: -- '2.7' -- '3.5' -- '3.6' -- '3.7' - -before_script: -- docker pull lkdevelopment/hetzner-cloud-api-mock -- docker run -d -p 127.0.0.1:4000:8080 lkdevelopment/hetzner-cloud-api-mock -- docker ps -a -- sleep 10 -install: -- pip install . -- pip install -r requirements/test.txt -- pip install tox-travis - -script: tox - -jobs: - include: - - stage: codingstyle - env: STAGE=style_check - python: "3.6" - script: tox -e flake8 - - stage: deploy - env: STAGE=deploy - if: tag IS present - python: "3.6" - script: echo "Deploy to pypi" - deploy: - provider: pypi - user: "$PYPIUSER" - password: - secure: IDBPB3zIu17qOuw1IePaEg2JGFiEDBpnhIWSr4qwCC6QwCyTcaQpn/Nt2GfkXI5M5Pp9eiePgRXVjriJPAVFaA/tC56gfAXiTs0I6/pY/i24f+TDr6Vp+73FV2q2u4vnhBgRjtXWLHEorhE2JfeUhnu+1rC49FP43NqOW3qFBEU09UkXsVZg1Z4mabWOew2GNe5kX5CVL9bLV2h96FutsM6VJsetVS/20gQrrIQJlHhmyLlSixj+z9p/m1cILy2lww/cNPgK9KLiLtNYibYRaI5uKShDHFtmxfGFNatsVEAcF0ZENBp4N44x5hQHxnNTaLjFTjNeRd7smCEAhULXCxVgo11Fz3yl1xQrTXEBkR7KcvKDnaAkjrYrwxRoGCmdWbBUMKpTjR7+/hcTr7TBI93u9GU+FZikVdba6CkVgy/tlTZjpz/Toxo7RSldRQxm9IsyP1HezgBgiQeU3JZnPDmANyMA2q3b4ee+QLj4NoblgEREEKju2x42gGbMQd0am8AvwOVnET/yMcd5vJxGQWEvRRzseLjzw0JJ7b295RMD1kxdfjYrMu3Lvt+WZY7DOTyfvaXU44EbWEFGRdfpHu9kgtl84NWEfKJc4p9bACL2Go+ROe9BeVzTPE9cFCemR2E4tvyHI2fq/GqtQ6P/Y7uSdLTtfasB9A2Xk9IBZRA= - on: - tags: true - condition: $STAGE=deploy - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2d744fe0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,929 @@ +# Changelog + +## [v2.23.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.23.0) + +[Compare to previous version](https://github.com/hetznercloud/hcloud-python/compare/v2.22.0...v2.23.0) + +### Removed deprecated Datacenter property from Server and PrimaryIP + +Removed the deprecated Datacenter property from the Server and PrimaryIP resources. Since the property was already removed from the Hetzner Cloud API, we do not consider this a breaking change (see [changelog entry](https://docs.hetzner.cloud/changelog#2026-07-01-removing-datacenters)). + +> [!IMPORTANT] +> **Action required:** Please update all code that accesses `server.datacenter` or `primary_ip.datacenter` to use the `location` property instead, as shown below. + +**Before:** + +```python +server = client.server.get_by_id(5) +print(server.datacenter) + +primary_ip = client.primary_ip.get_by_id(5) +print(primary_ip.datacenter) +``` + +**After:** + +```python +server = client.server.get_by_id(5) +print(server.location) + +primary_ip = client.primary_ip.get_by_id(5) +print(primary_ip.location) +``` + +### Features + +- remove datacenter property from server and primary_ip (#668) ([abdadf3](https://github.com/hetznercloud/hcloud-python/commit/abdadf3e7cc5d0edeb38f369955fa83f71b102e5)) +- add deprecation info to load balancer type (#674) ([aeebcf8](https://github.com/hetznercloud/hcloud-python/commit/aeebcf8bd32df390163089a33c57815bb7214763)) + +## [v2.22.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.22.0) + +### Datacenters resource is now deprecated + +The endpoints `GET /v1/datacenters` and `GET /v1/datacenters/{id}` are now deprecated and will be removed after 1 Oct. 2026. After this date, requests to these endpoints will return `HTTP 410 Gone`. + +The `DatacentersClient`, `Datacenter` and related classes are now deprecated. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated) for more details. + +### Features + +- deprecate datacenters (#656) + +## [v2.21.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.21.0) + +### Features + +- retry requests on api bad_gateway error (#658) + +## [v2.20.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.20.0) + +### Load Balancer HTTP Services now support `timeout_idle` + +HTTP Services now support the field `timeout_idle`, which controls the time a HTTP connection is allowed to idle before it is being dropped. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-30-load-balancers-http-idle-timeout-can-now-be-configured) for more information. + +### Features + +- **load-balancer**: support `timeout_idle` http service field (#649) + +## [v2.19.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.19.0) + +### Primary IPs `assignee_type` behavior change + +In the create Primary IP call, the `assignee_type` argument is now only send when the `assignee_id` argument is set. The `assignee_type` argument will stop defaulting to `server` in the near future, consider explicitly setting this argument when needed. + +As of 1 August 2026, the behavior of the Primary IP `assignee_type` property will change, and will return `unassigned` when the Primary IP is not assigned (when `assignee_id` is `null`). The goal is to eventually assign Primary IPs to other resource types, not only to `server`. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-will-return-unassigned) for more details. + +In addition, the Primary IP request body `assignee_type` property of the operation [`POST /v1/primary_ips`](https://docs.hetzner.cloud/reference/cloud#tag/primary-ips/create_primary_ip) is now optional. Primary IPs created without `assignee_type` return `server` until 1 August 2026, after this date, its value will be `unassigned`. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-27-primary-ips-make-assignee_type-optional) for more details. + +### Features + +- **primary-ip**: `assignee_type` behavior changed when creating a primary ip (#647) + +## [v2.18.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.18.0) + +### Available and recommended Server Types have been moved + +`Datacenter.server_types` has been deprecated in favor of the new `ServerType.locations[].available` and `ServerType.locations[].recommended` properties. + +See the [changelog](https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations) for more details. + +### Features + +- **datacenter, server_type**: move available and recommended to server_type (#645) + +## [v2.17.1](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.1) + +### Bug Fixes + +- missing `__api_properties__` on LoadBalancerService (#639) + +## [v2.17.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.17.0) + +### Features + +- parse nested load balancer `label_selector` targets (#633) + +## [v2.16.0](https://github.com/hetznercloud/hcloud-python/releases/tag/v2.16.0) + +### Storage Boxes support is now generally available + +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 1b8c885c..00000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,42 +0,0 @@ -======= -History -======= - -master(XXXX-XX-XX) ------------------- - -* Feature: Add status filter for servers, images and volumes -* Feature: Add 'created' property to Floating IP domain - -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 e204ac58..00000000 --- a/README.rst +++ /dev/null @@ -1,58 +0,0 @@ -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 -.. 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/`. - - -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 new file mode 100644 index 00000000..b5ec8500 --- /dev/null +++ b/docs/api.clients.certificates.rst @@ -0,0 +1,12 @@ +CertificateClient +================== + + +.. autoclass:: hcloud.certificates.client.CertificatesClient + :members: + +.. autoclass:: hcloud.certificates.client.BoundCertificate + :members: + +.. 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 new file mode 100644 index 00000000..ad7358a6 --- /dev/null +++ b/docs/api.clients.firewalls.rst @@ -0,0 +1,21 @@ +FirewallsClient +================== + + +.. autoclass:: hcloud.firewalls.client.FirewallsClient + :members: + +.. autoclass:: hcloud.firewalls.client.BoundFirewall + :members: + +.. autoclass:: hcloud.firewalls.domain.Firewall + :members: + +.. autoclass:: hcloud.firewalls.domain.FirewallRule + :members: + +.. autoclass:: hcloud.firewalls.domain.FirewallResource + :members: + +.. 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_balancer_types.rst b/docs/api.clients.load_balancer_types.rst new file mode 100644 index 00000000..3f9217e6 --- /dev/null +++ b/docs/api.clients.load_balancer_types.rst @@ -0,0 +1,9 @@ +LoadBalancerTypesClient +======================== + + +.. autoclass:: hcloud.load_balancer_types.client.LoadBalancerTypesClient + :members: + +.. autoclass:: hcloud.load_balancer_types.domain.LoadBalancerType + :members: diff --git a/docs/api.clients.load_balancers.rst b/docs/api.clients.load_balancers.rst new file mode 100644 index 00000000..dfb2780a --- /dev/null +++ b/docs/api.clients.load_balancers.rst @@ -0,0 +1,39 @@ +LoadBalancerClient +================== + + +.. autoclass:: hcloud.load_balancers.client.LoadBalancersClient + :members: + +.. autoclass:: hcloud.load_balancers.client.BoundLoadBalancer + :members: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancer + :members: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerService + :members: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerServiceHttp + :members: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerHealthCheck + :members: + +.. 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: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerTargetIP + :members: + +.. autoclass:: hcloud.load_balancers.domain.LoadBalancerAlgorithm + :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.networks.rst b/docs/api.clients.networks.rst new file mode 100644 index 00000000..7e75dd72 --- /dev/null +++ b/docs/api.clients.networks.rst @@ -0,0 +1,21 @@ +NetworksClient +================== + + +.. autoclass:: hcloud.networks.client.NetworksClient + :members: + +.. autoclass:: hcloud.networks.client.BoundNetwork + :members: + +.. autoclass:: hcloud.networks.domain.Network + :members: + +.. autoclass:: hcloud.networks.domain.NetworkSubnet + :members: + +.. autoclass:: hcloud.networks.domain.NetworkRoute + :members: + +.. autoclass:: hcloud.networks.domain.CreateNetworkResponse + :members: diff --git a/docs/api.clients.placement_groups.rst b/docs/api.clients.placement_groups.rst new file mode 100644 index 00000000..83457dfb --- /dev/null +++ b/docs/api.clients.placement_groups.rst @@ -0,0 +1,15 @@ +PlacementGroupsClient +===================== + + +.. autoclass:: hcloud.placement_groups.client.PlacementGroupsClient + :members: + +.. autoclass:: hcloud.placement_groups.client.BoundPlacementGroup + :members: + +.. autoclass:: hcloud.placement_groups.domain.PlacementGroup + :members: + +.. autoclass:: hcloud.placement_groups.domain.CreatePlacementGroupResponse + :members: 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 944869a2..7992a6ef --- a/docs/conf.py +++ b/docs/conf.py @@ -1,166 +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'] +sys.path.insert(0, os.path.abspath("..")) +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' - -# The master toctree document. -master_doc = 'index' +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html -# General information about the project. -project = u'Hetzner Cloud Python' -copyright = u"2019, Hetzner Cloud GmbH" -author = u"Hetzner Cloud GmbH" +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -# 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 - -# 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 +project = "Hetzner Cloud Python" +author = "Hetzner Cloud GmbH" +copyright = f"{datetime.now().year}, {author}" -# 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 -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +version = hcloud.__version__ +release = hcloud.__version__ -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False +extensions = ["myst_parser", "sphinx.ext.autodoc", "sphinx.ext.viewcode"] +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -add_module_names = False - -# -- Options for HTML output ------------------------------------------- - -# 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, +source_suffix = { + ".rst": "restructuredtext", + ".md": "markdown", } -# 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_static_path = ['_static'] - -# -- Options for HTMLHelp output --------------------------------------- - -# Output file base name for HTML help builder. -htmlhelp_basename = 'hclouddoc' - -# -- Options for LaTeX output ------------------------------------------ +# 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 -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', +# Myst Parser +myst_enable_extensions = ["colon_fence", "alert"] - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] - # 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'), +html_css_files = [ + "custom.css", ] - -# -- 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'), -] - -source_suffix = ['.rst'] - - -def setup(app): - app.add_javascript('js/open_links_in_new_tab.js') 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 cd134846..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-18.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 0ff936fc..7aea8fc7 100644 --- a/examples/create_server.py +++ b/examples/create_server.py @@ -1,9 +1,27 @@ +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=token, + application_name="examples", + application_version="unknown", +) -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("cx11"), image=Image(name="ubuntu-18.04")) +response = client.servers.create( + name="my-server", + server_type=ServerType(name="cx23"), + image=Image(name="ubuntu-24.04"), +) server = response.server print(server) -print("Root Passwort" + response.root_password) +print("Root Password" + response.root_password) 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 61d074e9..64cc10e7 100644 --- a/examples/list_servers.py +++ b/examples/list_servers.py @@ -1,5 +1,18 @@ +from __future__ import annotations + +from os import environ + from hcloud import Client -client = Client(token="{YOUR_API_TOKEN}") # Please paste your API token here between the quotes +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", +) servers = client.servers.get_all() print(servers) diff --git a/examples/usage_oop.py b/examples/usage_oop.py index 992b1d54..f788306d 100644 --- a/examples/usage_oop.py +++ b/examples/usage_oop.py @@ -1,22 +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 @@ -28,16 +37,8 @@ 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 -) +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 diff --git a/examples/usage_procedurale.py b/examples/usage_procedurale.py index 3a5c54bd..6c88cbe7 100644 --- a/examples/usage_procedurale.py +++ b/examples/usage_procedurale.py @@ -1,23 +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 @@ -32,16 +40,8 @@ # 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 -) +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 @@ -61,18 +61,14 @@ # Create one more volume and attach it to server with id=33 server33 = Server(id=33) -response = client.volumes.create( - size=33, - name="Volume33", - server=server33 -) +response = client.volumes.create(size=33, name="Volume33", server=server33) print(response.action.status) # 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)] + 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 f30a0b87..00000000 --- a/hcloud/__version__.py +++ /dev/null @@ -1 +0,0 @@ -VERSION = '1.2.1' diff --git a/hcloud/_client.py b/hcloud/_client.py new file mode 100644 index 00000000..4a71bb0b --- /dev/null +++ b/hcloud/_client.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import time +import warnings +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`` + - ``bad_gateway`` + - ``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, + ) + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + self.datacenters = DatacentersClient(self) + + 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) + + @property + def datacenters(self) -> DatacentersClient: + """DatacentersClient Instance + + .. deprecated:: 2.22.0 + The datacenters client is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + + :type: :class:`DatacentersClient ` + """ + warnings.warn( + "The datacenters client is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) + return self._datacenters + + @datacenters.setter + def datacenters(self, value: DatacentersClient) -> None: + self._datacenters = value + + +class ClientBase: + def __init__( + self, + token: str, + *, + endpoint: str, + application_name: str | None = None, + application_version: str | None = None, + poll_interval: int | float | BackoffFunction = 1.0, + poll_max_retries: int = 120, + timeout: float | tuple[float, float] | None = None, + ): + self._token = token + self._endpoint = endpoint + + self._user_agent = _build_user_agent(application_name, application_version) + self._headers = { + "User-Agent": self._user_agent, + "Authorization": f"Bearer {self._token}", + "Accept": "application/json", + } + + if isinstance(poll_interval, (int, float)): + poll_interval_func = constant_backoff_function(poll_interval) + else: + poll_interval_func = poll_interval + + self._poll_interval_func = poll_interval_func + self._poll_max_retries = poll_max_retries + + self._retry_interval_func = exponential_backoff_function( + base=1.0, multiplier=2, cap=60.0, jitter=True + ) + self._retry_max_retries = 5 + + 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", + "bad_gateway", + "timeout", + ) + + if isinstance(exception.code, int): + return exception.code in ( + HTTPStatus.BAD_GATEWAY, + HTTPStatus.GATEWAY_TIMEOUT, + ) + + return False diff --git a/hcloud/_exceptions.py b/hcloud/_exceptions.py 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..8408a231 --- /dev/null +++ b/hcloud/_version.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__version__ = "2.23.0" # x-releaser-pleaser-version diff --git a/hcloud/actions/__init__.py b/hcloud/actions/__init__.py index 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 91e7d9ba..176a6b18 100644 --- a/hcloud/actions/client.py +++ b/hcloud/actions/client.py @@ -1,67 +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 ` - """ - response = self._client.request(url="/actions/{action_id}".format(action_id=id), method="GET") - return BoundAction(self, response['action']) - - def get_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 = {} +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( + method="GET", + url=f"{base_url}/actions/{id}", + ) + return BoundAction( + client=self._parent.actions, + data=response["action"], + ) + + def _get_actions_list( + self, + base_url: str, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + params: dict[str, Any] = {} if status is not None: params["status"] = status if sort is not None: @@ -71,18 +111,124 @@ def get_list(self, 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) + 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 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) - 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 `] +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. `_ """ - return super(ActionsClient, self).get_all(status=status, sort=sort) + 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 9341f5a6..59b7f442 100644 --- a/hcloud/actions/domain.py +++ b/hcloud/actions/domain.py @@ -1,7 +1,28 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain +from __future__ import annotations -from hcloud.helpers.descriptors import ISODateTime +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): @@ -14,8 +35,9 @@ class Action(BaseDomain): :param started: Point in time when the action was started :param datetime,None finished: Point in time when the action was finished. Only set if the action is finished otherwise None :param resources: Resources the action relates to - :param error: Error message for the action if error occured, otherwise None. + :param error: Error message for the action if error occurred, otherwise None. """ + STATUS_RUNNING = "running" """Action Status running""" STATUS_SUCCESS = "success" @@ -23,51 +45,82 @@ class Action(BaseDomain): STATUS_ERROR = "error" """Action Status error""" - started = ISODateTime() - finished = ISODateTime() - - __slots__ = ( + __api_properties__ = ( "id", "command", "status", "progress", "resources", "error", + "started", + "finished", ) - - supported_fields = ("started", "finished") + __slots__ = __api_properties__ def __init__( - self, - id, - command=None, - status=None, - progress=None, - started=None, - finished=None, - resources=None, - error=None + self, + 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 = started - self.finished = finished + 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 - def __init__(self, action): - self.action = action + +class ActionError(TypedDict): + code: str + message: str + details: dict[str, Any] -class ActionTimeoutException(Exception): - """The Action you was waiting for timeouted in hcloud-python.""" +class ActionException(HCloudException): + """A generic action exception""" - def __init__(self, action): + def __init__(self, action: Action | BoundAction): + assert self.__doc__ is not None + message = self.__doc__ + + 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 new file mode 100644 index 00000000..5b85358e --- /dev/null +++ b/hcloud/certificates/__init__.py @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..46a6a538 --- /dev/null +++ b/hcloud/certificates/client.py @@ -0,0 +1,380 @@ +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 ( + Certificate, + CreateManagedCertificateResponse, + ManagedCertificateError, + ManagedCertificateStatus, +) + +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundCertificate", + "CertificatesPageResult", + "CertificatesClient", +] + + +class BoundCertificate(BoundModelBase[Certificate], Certificate): + _client: CertificatesClient + + model = Certificate + + 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") + error = None + if error_data: + error = ManagedCertificateError( + code=error_data["code"], message=error_data["message"] + ) + data["status"] = ManagedCertificateStatus( + issuance=status["issuance"], renewal=status["renewal"], error=error + ) + 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 Certificate. + + :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 Certificate. + + :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, + 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 self._client.update( + self, + name=name, + labels=labels, + ) + + def delete(self) -> bool: + """Deletes a certificate. + :return: boolean + """ + return self._client.delete(self) + + def retry_issuance(self) -> BoundAction: + """Retry a failed Certificate issuance or renewal. + :return: BoundAction + """ + return self._client.retry_issuance(self) + + +class CertificatesPageResult(NamedTuple): + certificates: list[BoundCertificate] + meta: Meta + + +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=f"{self._base_url}/{id}", method="GET") + return BoundCertificate(self, response["certificate"]) + + def get_list( + self, + 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) + Can be used to filter certificates by their name. + :param label_selector: str (optional) + Can be used to filter certificates by labels. The response will only contain certificates matching the label selector. + :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:`BoundCertificate `], :class:`Meta `) + """ + 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 + + response = self._client.request(url=self._base_url, method="GET", params=params) + + certificates = [ + BoundCertificate(self, certificate_data) + for certificate_data in response["certificates"] + ] + + return CertificatesPageResult(certificates, Meta.parse_meta(response)) + + def get_all( + self, + name: str | None = None, + label_selector: str | None = None, + ) -> list[BoundCertificate]: + """Get all certificates + + :param name: str (optional) + Can be used to filter certificates by their name. + :param label_selector: str (optional) + Can be used to filter certificates by labels. The response will only contain certificates matching the label selector. + :return: List[:class:`BoundCertificate `] + """ + return self._iter_pages(self.get_list, name=name, label_selector=label_selector) + + 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 self._get_first_by(self.get_list, name=name) + + 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` + + :param name: str + :param certificate: str + Certificate and chain in PEM format, in order so that each record directly certifies the one preceding + :param private_key: str + Certificate key in PEM format + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundCertificate ` + """ + data: dict[str, Any] = { + "name": name, + "certificate": certificate, + "private_key": private_key, + "type": Certificate.TYPE_UPLOADED, + } + if labels is not None: + data["labels"] = labels + response = self._client.request(url=self._base_url, method="POST", json=data) + return BoundCertificate(self, response["certificate"]) + + 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` + + :param name: str + :param domain_names: List[str] + Domains and subdomains that should be contained in the Certificate + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundCertificate ` + """ + 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=self._base_url, method="POST", json=data) + return CreateManagedCertificateResponse( + certificate=BoundCertificate(self, response["certificate"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + 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 ` + :param name: str (optional) + New name to set + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundCertificate ` + """ + 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=f"{self._base_url}/{certificate.id}", + method="PUT", + json=data, + ) + return BoundCertificate(self, response["certificate"]) + + 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: Certificate | BoundCertificate, + 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 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, + ) + + 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: Certificate to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + certificate, + status=status, + sort=sort, + ) + + 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=f"{self._base_url}/{certificate.id}/actions/retry", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/certificates/domain.py b/hcloud/certificates/domain.py new file mode 100644 index 00000000..677ab8f2 --- /dev/null +++ b/hcloud/certificates/domain.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +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): + """Certificate Domain + + :param id: int ID of Certificate + :param name: str Name of Certificate + :param certificate: str Certificate and chain in PEM format, in order so that each record directly certifies the one preceding + :param not_valid_before: datetime + Point in time when the Certificate becomes valid + :param not_valid_after: datetime + Point in time when the Certificate becomes invalid + :param domain_names: List[str] List of domains and subdomains covered by this certificate + :param fingerprint: str Fingerprint of the Certificate + :param labels: dict + User-defined labels (key-value pairs) + :param created: datetime + Point in time when the certificate was created + :param type: str Type of Certificate + :param status: ManagedCertificateStatus Current status of a type managed Certificate, always none for type uploaded Certificates + """ + + __api_properties__ = ( + "id", + "name", + "certificate", + "not_valid_before", + "not_valid_after", + "domain_names", + "fingerprint", + "created", + "labels", + "type", + "status", + ) + __slots__ = __api_properties__ + + TYPE_UPLOADED = "uploaded" + TYPE_MANAGED = "managed" + + def __init__( + self, + 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 + self.type = type + self.certificate = certificate + self.domain_names = domain_names + self.fingerprint = fingerprint + 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 + + +class ManagedCertificateStatus(BaseDomain): + """ManagedCertificateStatus Domain + + :param issuance: str + Status of the issuance process of the Certificate + :param renewal: str + Status of the renewal process of the Certificate + :param error: ManagedCertificateError + If issuance or renewal reports failure, this property contains information about what happened + """ + + __api_properties__ = ( + "issuance", + "renewal", + "error", + ) + __slots__ = __api_properties__ + + def __init__( + self, + issuance: str | None = None, + renewal: str | None = None, + error: ManagedCertificateError | None = None, + ): + self.issuance = issuance + self.renewal = renewal + self.error = error + + +class ManagedCertificateError(BaseDomain): + """ManagedCertificateError Domain + + :param code: str + Error code identifying the error + :param message: + Message detailing the error + """ + + __api_properties__ = ( + "code", + "message", + ) + __slots__ = __api_properties__ + + def __init__(self, code: str | None = None, message: str | None = None): + self.code = code + self.message = message + + +class CreateManagedCertificateResponse(BaseDomain): + """Create Managed Certificate Response Domain + + :param certificate: :class:`BoundCertificate ` + The created server + :param action: :class:`BoundAction ` + Shows the progress of the certificate creation + """ + + __api_properties__ = ("certificate", "action") + __slots__ = __api_properties__ + + def __init__( + self, + 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 917a9cf8..45f5039c 100644 --- a/hcloud/core/client.py +++ b/hcloud/core/client.py @@ -1,88 +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", +] - 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(self, - list_function, # type: function - results_list_attribute_name, # type: str - *args, - **kwargs - ): - # type (...) -> List[BoundModelBase] - page = 1 +T = TypeVar("T") + + +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: Callable[..., tuple[list[T], Meta]], + *args, + **kwargs, + ) -> list[T]: results = [] + page = 1 while page: - page_result = list_function(page=page, per_page=self.max_per_page, *args, **kwargs) - result = getattr(page_result, results_list_attribute_name) + # 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 + ) 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 - def __init__(self, client, data={}, complete=True): + model: type[Domain] + + def __init__( + self, + client: ResourceClientBase, + data: dict[str, Any], + complete: bool = True, + ): """ :param client: The client for the specific model to use @@ -93,9 +109,12 @@ def __init__(self, client, data={}, complete=True): """ self._client = client self.complete = complete - self.data_model = self.model.from_dict(data) - def __getattr__(self, name): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + self.data_model: Domain = self.model.from_dict(data) + + def __getattr__(self, name: str): # type: ignore[no-untyped-def] """Allow magical access to the properties of the model :param name: str :return: @@ -106,9 +125,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 8fe27d81..6659df80 100644 --- a/hcloud/core/domain.py +++ b/hcloud/core/domain.py @@ -1,38 +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 - supported_fields = () +__all__ = [ + "BaseDomain", + "DomainIdentityMixin", + "Pagination", + "Meta", +] - @classmethod - def get_supported_fields(cls): - return set(cls.__slots__ + cls.supported_fields) + +class BaseDomain: + __api_properties__: tuple[str, ...] @classmethod - def from_dict(cls, data): - supported_fields = cls.get_supported_fields() - supported_data = {k: v for k, v in data.items() if k in supported_fields} + 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: -class DomainIdentityMixin(object): - __slots__ = () + 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", @@ -40,8 +99,17 @@ 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): + def __init__( + self, + 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 self.previous_page = previous_page @@ -51,34 +119,22 @@ def __init__(self, page, per_page, previous_page=None, next_page=None, last_page 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 779ce56f..f8a7f3b0 100644 --- a/hcloud/datacenters/client.py +++ b/hcloud/datacenters/client.py @@ -1,52 +1,112 @@ -# -*- 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 +import warnings +from typing import TYPE_CHECKING, Any, NamedTuple +from ..core import BoundModelBase, Meta, ResourceClientBase +from ..locations import BoundLocation +from ..server_types import BoundServerType +from .domain import Datacenter, DatacenterServerTypes + +if TYPE_CHECKING: + from .._client import Client + +__all__ = [ + "BoundDatacenter", + "DatacentersPageResult", + "DatacentersClient", +] + + +class BoundDatacenter(BoundModelBase[Datacenter], Datacenter): + """ + .. deprecated:: 2.22.0 + The bound datacenter class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + + _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) for - server_type in server_types['available']] - supported = [BoundServerType(client._client.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) - for server_type in server_types['available_for_migration']] - data['server_types'] = DatacenterServerTypes(available=available, supported=supported, - available_for_migration=available_for_migration) - - super(BoundDatacenter, self).__init__(client, data) - - -class DatacentersClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = 'datacenters' - - def get_by_id(self, id): - # type: (int) -> BoundDatacenter + available = [ + BoundServerType( + client._parent.server_types, {"id": server_type}, complete=False + ) + for server_type in server_types["available"] + ] + supported = [ + BoundServerType( + client._parent.server_types, {"id": server_type}, complete=False + ) + for server_type in server_types["supported"] + ] + available_for_migration = [ + BoundServerType( + client._parent.server_types, {"id": server_type}, complete=False + ) + for server_type in server_types["available_for_migration"] + ] + data["server_types"] = DatacenterServerTypes( + available=available, + supported=supported, + available_for_migration=available_for_migration, + ) + + super().__init__(client, data) + + +class DatacentersPageResult(NamedTuple): + """ + .. deprecated:: 2.22.0 + The datacenters page result class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + + datacenters: list[BoundDatacenter] + meta: Meta + + +class DatacentersClient(ResourceClientBase): + """ + .. deprecated:: 2.22.0 + The datacenters client class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + """ + + _base_url = "/datacenters" + + def __init__(self, client: Client): + warnings.warn( + "The datacenters client class is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) + super().__init__(client) + + def get_by_id(self, id: int) -> BoundDatacenter: """Get a specific datacenter by its ID. :param id: int :return: :class:`BoundDatacenter ` """ - response = self._client.request(url="/datacenters/{datacenter_id}".format(datacenter_id=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] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundDatacenter(self, response["datacenter"]) + + def get_list( + self, + name: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> DatacentersPageResult: """Get a list of datacenters :param name: str (optional) @@ -57,38 +117,39 @@ def get_list(self, 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 if page is not None: - params['page'] = page + params["page"] = page if per_page is not None: - params['per_page'] = per_page + 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']] + 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 aeea319e..aef7d700 100644 --- a/hcloud/datacenters/domain.py +++ b/hcloud/datacenters/domain.py @@ -1,42 +1,88 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin +from __future__ import annotations + +import warnings +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): """Datacenter Domain + .. deprecated:: 2.22.0 + The datacenters domain class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + :param id: int ID of Datacenter :param name: str Name of Datacenter :param description: str Description of Datacenter :param location: :class:`BoundLocation ` :param server_types: :class:`DatacenterServerTypes ` """ - __slots__ = ( - "id", - "name", - "description", - "location", - "server_types", - ) + + __properties__ = ("id", "name", "description", "location") + __api_properties__ = (*__properties__, "server_types") + __slots__ = (*__properties__, "_server_types") def __init__( self, - id=None, - name=None, - description=None, - location=None, - server_types=None + id: int | None = None, + name: str | None = None, + description: str | None = None, + location: Location | None = None, + server_types: DatacenterServerTypes | None = None, ): + warnings.warn( + "The datacenter domain class is deprecated and will be removed after the 2026-10-01. " + "See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated.", + DeprecationWarning, + stacklevel=2, + ) self.id = id self.name = name self.description = description self.location = location - self.server_types = server_types + self._server_types = server_types + + @property + def server_types(self) -> DatacenterServerTypes | None: + """ + .. deprecated:: 2.18.0 + The 'server_types' property is deprecated and will not be supported after 2026-10-01. + Please use 'server_type.locations[]' instead. + See https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations. + """ + warnings.warn( + "The 'server_types' property is deprecated and will not be supported after 2026-10-01. " + "Please use 'server_type.locations[]' instead. " + "See https://docs.hetzner.cloud/changelog#2026-04-01-datacenter-deprecations.", + DeprecationWarning, + stacklevel=2, + ) + return self._server_types -class DatacenterServerTypes: + @server_types.setter + def server_types(self, value: DatacenterServerTypes | None) -> None: + self._server_types = value + + +class DatacenterServerTypes(BaseDomain): """DatacenterServerTypes Domain + .. deprecated:: 2.22.0 + The datacenters domain class is deprecated and will be removed after the 2026-10-01. + See https://docs.hetzner.cloud/changelog#2026-06-02-datacenters-deprecated. + :param available: List[:class:`BoundServerTypes `] All available server types for this datacenter :param supported: List[:class:`BoundServerTypes `] @@ -44,13 +90,16 @@ class DatacenterServerTypes: :param available_for_migration: List[:class:`BoundServerTypes `] All available for migration (change type) server types for this datacenter """ - __slots__ = ( - "available", - "supported", - "available_for_migration" - ) - def __init__(self, available, supported, available_for_migration): + __api_properties__ = ("available", "supported", "available_for_migration") + __slots__ = __api_properties__ + + 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 new file mode 100644 index 00000000..f81ae0df --- /dev/null +++ b/hcloud/firewalls/__init__.py @@ -0,0 +1,23 @@ +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 new file mode 100644 index 00000000..f314b68a --- /dev/null +++ b/hcloud/firewalls/client.py @@ -0,0 +1,507 @@ +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 ( + CreateFirewallResponse, + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + FirewallResourceLabelSelector, + FirewallRule, +) + +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundFirewall", + "FirewallsPageResult", + "FirewallsClient", +] + + +class BoundFirewall(BoundModelBase[Firewall], Firewall): + _client: FirewallsClient + + model = Firewall + + def __init__( + self, + client: FirewallsClient, + data: dict[str, Any], + complete: bool = True, + ): + rules = data.get("rules", []) + if rules: + rules = [ + FirewallRule( + direction=rule["direction"], + source_ips=rule["source_ips"], + destination_ips=rule["destination_ips"], + protocol=rule["protocol"], + port=rule["port"], + description=rule["description"], + ) + for rule in rules + ] + data["rules"] = rules + + applied_to = data.get("applied_to", []) + if applied_to: + # 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") + ] + + if firewall_resource["type"] == FirewallResource.TYPE_SERVER: + data_applied_to.append( + FirewallResource( + type=firewall_resource["type"], + server=BoundServer( + client._parent.servers, + firewall_resource["server"], + complete=False, + ), + applied_to_resources=applied_to_resources, + ) + ) + elif firewall_resource["type"] == FirewallResource.TYPE_LABEL_SELECTOR: + data_applied_to.append( + FirewallResource( + type=firewall_resource["type"], + label_selector=FirewallResourceLabelSelector( + selector=firewall_resource["label_selector"]["selector"] + ), + applied_to_resources=applied_to_resources, + ) + ) + + data["applied_to"] = data_applied_to + + 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 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, + ) + + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Firewall. + + :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, + 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) + User-defined labels (key-value pairs) + :param name: str (optional) + New Name to set + :return: :class:`BoundFirewall ` + """ + return self._client.update(self, name=name, labels=labels) + + def delete(self) -> bool: + """Deletes a Firewall. + + :return: boolean + """ + return self._client.delete(self) + + 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=rules) + + 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=resources) + + 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=resources) + + +class FirewallsPageResult(NamedTuple): + firewalls: list[BoundFirewall] + meta: Meta + + +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: 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: 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. + """ + return self._get_actions_list( + f"{self._base_url}/{firewall.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + firewall: Firewall | BoundFirewall, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Firewall. + + :param firewall: Firewall to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + firewall, + status=status, + sort=sort, + ) + + def get_by_id(self, id: int) -> BoundFirewall: + """Returns a specific Firewall object. + + :param id: int + :return: :class:`BoundFirewall ` + """ + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundFirewall(self, response["firewall"]) + + def get_list( + self, + 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) + Can be used to filter Firewalls by labels. The response will only contain Firewalls matching the label selector 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 sort: List[str] (optional) + 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: 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 sort is not None: + params["sort"] = sort + response = self._client.request(url=self._base_url, method="GET", params=params) + firewalls = [ + BoundFirewall(self, firewall_data) + for firewall_data in response["firewalls"] + ] + + return FirewallsPageResult(firewalls, Meta.parse_meta(response)) + + 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) + Can be used to filter Firewalls by labels. The response will only contain Firewalls matching the label selector values. + :param name: str (optional) + Can be used to filter networks by their name. + :param sort: List[str] (optional) + Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) + :return: List[:class:`BoundFirewall `] + """ + return self._iter_pages( + self.get_list, + label_selector=label_selector, + name=name, + sort=sort, + ) + + 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 self._get_first_by(self.get_list, name=name) + + def create( + self, + name: str, + rules: list[FirewallRule] | None = None, + labels: str | None = None, + resources: list[FirewallResource] | None = None, + ) -> CreateFirewallResponse: + """Creates a new Firewall. + + :param name: str + Firewall Name + :param rules: List[:class:`FirewallRule `] (optional) + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param resources: List[:class:`FirewallResource `] (optional) + :return: :class:`CreateFirewallResponse ` + """ + + data: dict[str, Any] = {"name": name} + if labels is not None: + data["labels"] = labels + + if rules is not None: + data.update({"rules": []}) + for rule in rules: + data["rules"].append(rule.to_payload()) + if resources is not None: + data.update({"apply_to": []}) + for resource in resources: + data["apply_to"].append(resource.to_payload()) + response = self._client.request(url=self._base_url, json=data, method="POST") + + actions = [] + if response.get("actions") is not None: + actions = [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] + + result = CreateFirewallResponse( + firewall=BoundFirewall(self, response["firewall"]), actions=actions + ) + return result + + 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 ` + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param name: str (optional) + New name to set + :return: :class:`BoundFirewall ` + """ + 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=f"{self._base_url}/{firewall.id}", + method="PUT", + json=data, + ) + return BoundFirewall(self, response["firewall"]) + + def delete(self, firewall: Firewall | BoundFirewall) -> bool: + """Deletes a Firewall. + + :param firewall: :class:`BoundFirewall ` or :class:`Firewall ` + :return: boolean + """ + self._client.request( + 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: 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: dict[str, Any] = {"rules": []} + for rule in rules: + data["rules"].append(rule.to_payload()) + response = self._client.request( + url=f"{self._base_url}/{firewall.id}/actions/set_rules", + method="POST", + json=data, + ) + return [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] + + 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: dict[str, Any] = {"apply_to": []} + for resource in resources: + data["apply_to"].append(resource.to_payload()) + response = self._client.request( + url=f"{self._base_url}/{firewall.id}/actions/apply_to_resources", + method="POST", + json=data, + ) + return [ + BoundAction(self._parent.actions, action_data) + for action_data in response["actions"] + ] + + 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: dict[str, Any] = {"remove_from": []} + for resource in resources: + data["remove_from"].append(resource.to_payload()) + response = self._client.request( + url=f"{self._base_url}/{firewall.id}/actions/remove_from_resources", + method="POST", + json=data, + ) + 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 new file mode 100644 index 00000000..76a9b596 --- /dev/null +++ b/hcloud/firewalls/domain.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..core import BaseDomain, DomainIdentityMixin + +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 + ID of the Firewall + :param name: str + Name of the Firewall + :param labels: dict + User-defined labels (key-value pairs) + :param rules: List[:class:`FirewallRule `] + Rules of the Firewall + :param applied_to: List[:class:`FirewallResource `] + Resources currently using the Firewall + :param created: datetime + Point in time when the image was created + """ + + __api_properties__ = ("id", "name", "labels", "rules", "applied_to", "created") + __slots__ = __api_properties__ + + def __init__( + 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 = self._parse_datetime(created) + + +class FirewallRule(BaseDomain): + """Firewall Rule Domain + + :param direction: str + The Firewall which was created + :param port: str + Port to which traffic will be allowed, only applicable for protocols TCP and UDP, specify port ranges by using + - as a indicator, Sample: 80-85 means all ports between 80 & 85 (80, 82, 83, 84, 85) + :param protocol: str + Select traffic direction on which rule should be applied. Use source_ips for direction in and destination_ips for direction out. + :param source_ips: List[str] + List of permitted IPv4/IPv6 addresses in CIDR notation. Use 0.0.0.0/0 to allow all IPv4 addresses and ::/0 to allow all IPv6 addresses. You can specify 100 CIDRs at most. + :param destination_ips: List[str] + List of permitted IPv4/IPv6 addresses in CIDR notation. Use 0.0.0.0/0 to allow all IPv4 addresses and ::/0 to allow all IPv6 addresses. You can specify 100 CIDRs at most. + :param description: str + Short description of the firewall rule + """ + + __api_properties__ = ( + "direction", + "port", + "protocol", + "source_ips", + "destination_ips", + "description", + ) + __slots__ = __api_properties__ + + DIRECTION_IN = "in" + """Firewall Rule Direction In""" + DIRECTION_OUT = "out" + """Firewall Rule Direction Out""" + + PROTOCOL_UDP = "udp" + """Firewall Rule Protocol UDP""" + PROTOCOL_ICMP = "icmp" + """Firewall Rule Protocol ICMP""" + PROTOCOL_TCP = "tcp" + """Firewall Rule Protocol TCP""" + PROTOCOL_ESP = "esp" + """Firewall Rule Protocol ESP""" + PROTOCOL_GRE = "gre" + """Firewall Rule Protocol GRE""" + + def __init__( + self, + 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 or [] + self.destination_ips = destination_ips or [] + self.description = description + + 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, + } + if len(self.source_ips) > 0: + payload["source_ips"] = self.source_ips + if len(self.destination_ips) > 0: + payload["destination_ips"] = self.destination_ips + if self.port is not None: + payload["port"] = self.port + if self.description is not None: + payload["description"] = self.description + return payload + + +class FirewallResource(BaseDomain): + """Firewall Used By Domain + + :param type: str + Type of resource referenced + :param server: Optional[Server] + 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. + """ + + __api_properties__ = ("type", "server", "label_selector", "applied_to_resources") + __slots__ = __api_properties__ + + TYPE_SERVER = "server" + """Firewall Used By Type Server""" + TYPE_LABEL_SELECTOR = "label_selector" + """Firewall Used By Type label_selector""" + + def __init__( + self, + 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) -> 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["server"] = {"id": self.server.id} + + if self.label_selector is not None: + 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 + """ + + __api_properties__ = ("selector",) + __slots__ = __api_properties__ + + def __init__(self, selector: str | None = None): + self.selector = selector + + +class CreateFirewallResponse(BaseDomain): + """Create Firewall Response Domain + + :param firewall: :class:`BoundFirewall ` + The Firewall which was created + :param actions: List[:class:`BoundAction `] + The Action which shows the progress of the Firewall Creation + """ + + __api_properties__ = ("firewall", "actions") + __slots__ = __api_properties__ + + def __init__( + self, + 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 ec397b49..88e7ea42 100644 --- a/hcloud/floating_ips/client.py +++ b/hcloud/floating_ips/client.py @@ -1,106 +1,148 @@ -# -*- coding: utf-8 -*- -from hcloud.actions.client import BoundAction -from hcloud.core.client import BoundModelBase, ClientEntityBase -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) + data["server"] = BoundServer( + 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) - - 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 `) + data["home_location"] = BoundLocation( + client._parent.locations, home_location + ) + + 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: 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 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, + ) - :return: List[:class:`BoundAction `] + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: """ - return self._client.get_actions(self, status, sort) + Returns all Actions for a Floating IP. - def update(self, description=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundFloatingIP + :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, + 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) New Description to set :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) + :param name: str (optional) + New Name to set :return: :class:`BoundFloatingIP ` """ - return self._client.update(self, description, labels) + 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 @@ -109,82 +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 FloatingIPsClient(ClientEntityBase): - results_list_attribute_name = 'floating_ips' +class FloatingIPsPageResult(NamedTuple): + floating_ips: list[BoundFloatingIP] + meta: Meta - 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. - :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 `) +class FloatingIPsClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/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: FloatingIP | BoundFloatingIP, + 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 - response = self._client.request( - url="/floating_ips/{floating_ip_id}/actions".format(floating_ip_id=floating_ip.id), 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') - - 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. + 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 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. + """ + return self._get_actions_list( + f"{self._base_url}/{floating_ip.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + 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") - 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] - ): - # type: (...) -> PageResults[List[BoundFloatingIP]] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundFloatingIP(self, response["floating_ip"]) + + def get_list( + self, + 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) @@ -193,40 +244,62 @@ def get_list(self, 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. :return: (List[:class:`BoundFloatingIP `], :class:`Meta `) """ - params = {} - - if label_selector: - params['label_selector'] = label_selector - if page: - params['page'] = page - if per_page: - params['per_page'] = per_page - - response = self._client.request(url="/floating_ips", method="GET", params=params) - floating_ips = [BoundFloatingIP(self, floating_ip_data) for floating_ip_data in response['floating_ips']] + params: dict[str, Any] = {} - return self._add_meta_to_result(floating_ips, response) - - def get_all(self, label_selector=None): - # type: (Optional[str]) -> List[BoundFloatingIP] + 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 + + 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 FloatingIPsPageResult(floating_ips, Meta.parse_meta(response)) + + 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) Can be used to filter Floating IPs by labels. The response will only contain Floating IPs matching the label selector.able values. + :param name: str (optional) + Can be used to filter networks by their name. :return: List[:class:`BoundFloatingIP `] """ - return super(FloatingIPsClient, self).get_all(label_selector=label_selector) - - 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] - ): - # type: (...) -> CreateFloatingIPResponse + return self._iter_pages(self.get_list, label_selector=label_selector, name=name) + + 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 self._get_first_by(self.get_list, name=name) + + def create( + self, + 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 @@ -238,35 +311,40 @@ def create(self, Home location (routing is optimized for that location). Only optional if server argument is passed. :param server: :class:`BoundServer ` or :class:`Server ` Server to assign the Floating IP to + :param name: str (optional) :return: :class:`CreateFloatingIPResponse ` """ - data = { - 'type': type - } + data: dict[str, Any] = {"type": type} if description is not None: - data['description'] = description + data["description"] = description if labels is not None: - data['labels'] = labels + data["labels"] = labels if home_location is not None: - data['home_location'] = home_location.id_or_name + data["home_location"] = home_location.id_or_name if server is not None: - data['server'] = server.id + data["server"] = server.id + 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']) + if response.get("action") is not None: + action = BoundAction(self._parent.actions, response["action"]) result = CreateFloatingIPResponse( - floating_ip=BoundFloatingIP(self, response['floating_ip']), - action=action + floating_ip=BoundFloatingIP(self, response["floating_ip"]), action=action ) return result - def update(self, floating_ip, description=None, labels=None): - # type: (FloatingIP, Optional[str], Optional[Dict[str, 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 ` @@ -274,32 +352,43 @@ def update(self, floating_ip, description=None, labels=None): New Description to set :param labels: Dict[str, str] (optional) User-defined labels (key-value pairs) + :param name: str (optional) + New name to set :return: :class:`BoundFloatingIP ` """ - data = {} + data: dict[str, Any] = {} if description is not None: - data['description'] = description + data["description"] = description if labels is not None: - data['labels'] = labels + data["labels"] = labels + if name is not None: + data["name"] = name - response = self._client.request(url="/floating_ips/{floating_ip_id}".format(floating_ip_id=floating_ip.id), - method="PUT", json=data) - return BoundFloatingIP(self, response['floating_ip']) + response = self._client.request( + 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), - method="DELETE") + self._client.request( + 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 ` @@ -307,17 +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), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + url=f"{self._base_url}/{floating_ip.id}/actions/change_protection", + method="POST", + json=data, + ) + 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 ` @@ -326,23 +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), method="POST", - json={"server": server.id}) - return BoundAction(self._client.actions, response['action']) + url=f"{self._base_url}/{floating_ip.id}/actions/assign", + method="POST", + json={"server": server.id}, + ) + 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), method="POST") - return BoundAction(self._client.actions, response['action']) - - def change_dns_ptr(self, floating_ip, ip, dns_ptr): - # type: (FloatingIP, str, str) -> BoundAction + url=f"{self._base_url}/{floating_ip.id}/actions/unassign", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` @@ -353,6 +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), - method="POST", json={"ip": ip, "dns_ptr": dns_ptr}) - return BoundAction(self._client.actions, response['action']) + url=f"{self._base_url}/{floating_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/floating_ips/domain.py b/hcloud/floating_ips/domain.py index f2b3b525..3946da7b 100644 --- a/hcloud/floating_ips/domain.py +++ b/hcloud/floating_ips/domain.py @@ -1,10 +1,25 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain +from __future__ import annotations -from hcloud.helpers.descriptors import ISODateTime +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 @@ -29,8 +44,11 @@ class FloatingIP(BaseDomain): User-defined labels (key-value pairs) :param created: datetime Point in time when the Floating IP was created + :param name: str + Name of the Floating IP """ - __slots__ = ( + + __api_properties__ = ( "id", "type", "description", @@ -40,24 +58,26 @@ class FloatingIP(BaseDomain): "home_location", "blocked", "protection", - "labels" + "labels", + "name", + "created", ) - created = ISODateTime() - supported_fields = ("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 + 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 @@ -69,7 +89,12 @@ def __init__( self.blocked = blocked self.protection = protection self.labels = labels - self.created = created + self.created = self._parse_datetime(created) + self.name = name + + +class FloatingIPProtection(TypedDict): + delete: bool class CreateFloatingIPResponse(BaseDomain): @@ -80,15 +105,14 @@ class CreateFloatingIPResponse(BaseDomain): :param action: :class:`BoundAction ` 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 + self, + 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 1943de5f..00000000 --- a/hcloud/hcloud.py +++ /dev/null @@ -1,190 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import - -import time -import requests - -from hcloud.actions.client import ActionsClient -from hcloud.floating_ips.client import FloatingIPsClient -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 .__version__ import VERSION - - -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.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 ` - """ - - 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 = requests.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/descriptors.py b/hcloud/helpers/descriptors.py deleted file mode 100644 index 3940c721..00000000 --- a/hcloud/helpers/descriptors.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -from dateutil.parser import isoparse - - -class ISODateTime(object): - def __init__(self, initval=None): - self.val = initval - - def __get__(self, obj, obj_type): - return self.val - - def __set__(self, obj, string_date): - if string_date is None: - self.val = None - else: - # 2016-01-30T23:50+00:00 - self.val = isoparse(string_date) 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 988cf716..2566a96f 100644 --- a/hcloud/images/client.py +++ b/hcloud/images/client.py @@ -1,55 +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) + data["created_from"] = BoundServer( + 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) - - super(BoundImage, self).__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. - - :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 `) + data["bound_to"] = BoundServer( + client._parent.servers, {"id": bound_to}, complete=False + ) + + super().__init__(client, data) + + 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, sort=sort, page=page, per_page=per_page, status=status) + Returns a paginated list of Actions for a Image. - def get_actions(self, sort=None, status=None): - # type: (Optional[List[str]], Optional[List[str]]) -> List[BoundAction] - """Returns all action objects for the 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. + :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, + ) + + def get_actions( + self, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: """ - return self._client.get_actions(self, status=status, sort=sort) + Returns all Actions for a Image. - def update(self, description=None, type=None, labels=None): - # type: (Optional[str], Optional[Dict[str, str]]) -> BoundImage + :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, + 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) @@ -61,102 +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 ImagesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = 'images' +class ImagesPageResult(NamedTuple): + images: list[BoundImage] + meta: Meta - 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. - :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 `) +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: Image | BoundImage, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: """ - 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) - 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. + 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` `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. + :param page: Page number to get. + :param per_page: Maximum number of Actions returned per page. """ - return super(ImagesClient, self).get_actions(image, sort=sort, status=status) + return self._get_actions_list( + f"{self._base_url}/{image.id}", + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + image: Image | BoundImage, + status: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Image. - def get_by_id(self, id): - # type: (int) -> BoundImage + :param image: Image to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + image, + status=status, + sort=sort, + ) + + def get_by_id(self, id: int) -> BoundImage: """Get a specific Image :param id: int - :return: :class:`BoundImage PageResults[List[BoundImage]] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundImage(self, response["image"]) + + def get_list( + self, + 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) @@ -167,48 +227,57 @@ def get_list(self, 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) Choices: id id:asc id:desc name name:asc name:desc created created:asc created:desc + :param include_deprecated: bool (optional) + Include deprecated images in the response. Default: False :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:`BoundImage `], :class:`Meta `) """ - params = {} - if name: - params['name'] = name - if label_selector: - params['label_selector'] = label_selector - if bound_to: - params['bound_to'] = bound_to - if type: - params['type'] = type - if sort: - params['sort'] = sort - if page: - params['page'] = page - if per_page: - params['per_page'] = per_page - if status: - params['status'] = per_page - - response = self._client.request(url="/images", method="GET", params=params) - images = [BoundImage(self, image_data) for image_data in response['images']] - - return self._add_meta_to_result(images, 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]] - ): - # type: (...) -> List[BoundImage] + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if label_selector is not None: + params["label_selector"] = label_selector + if bound_to is not None: + 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: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + if status is not None: + params["status"] = per_page + if include_deprecated is not None: + params["include_deprecated"] = include_deprecated + response = self._client.request(url=self._base_url, method="GET", params=params) + images = [BoundImage(self, image_data) for image_data in response["images"]] + + return ImagesPageResult(images, Meta.parse_meta(response)) + + def get_all( + self, + 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) @@ -219,26 +288,77 @@ def get_all(self, 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) Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) + :param include_deprecated: bool (optional) + Include deprecated images in the response. Default: False :return: List[:class:`BoundImage `] """ - return super(ImagesClient, self).get_all(name=name, label_selector=label_selector, bound_to=bound_to, type=type, sort=sort, status=status) - - def get_by_name(self, name): - # type: (str) -> BoundImage + 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: 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. """ - return super(ImagesClient, self).get_by_name(name) + 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 - def update(self, image, description=None, type=None, labels=None): - # type:(Image, Optional[str], Optional[str], Optional[Dict[str, str]]) -> BoundImage + :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 self._get_first_by( + self.get_list, + name=name, + architecture=[architecture], + include_deprecated=include_deprecated, + ) + + 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 ` @@ -251,29 +371,33 @@ 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: data.update({"type": type}) 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) - return BoundImage(self, response['image']) + response = self._client.request( + 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 ` @@ -281,9 +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), method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{image.id}/actions/change_protection", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/images/domain.py b/hcloud/images/domain.py index ea533aee..915ace64 100644 --- a/hcloud/images/domain.py +++ b/hcloud/images/domain.py @@ -1,6 +1,20 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin -from hcloud.helpers.descriptors import ISODateTime +from __future__ import annotations + +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): @@ -9,7 +23,7 @@ class Image(BaseDomain, DomainIdentityMixin): :param id: int ID of the image :param type: str - Type of the image Choices: `system`, `snapshot`, `backup` + Type of the image Choices: `system`, `snapshot`, `backup`, `app` :param status: str Whether the image can be used or if it’s still being created Choices: `available`, `creating` :param name: str, None @@ -30,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 @@ -39,10 +55,8 @@ class Image(BaseDomain, DomainIdentityMixin): :param labels: Dict User-defined labels (key-value pairs) """ - created = ISODateTime() - deprecated = ISODateTime() - __slots__ = ( + __api_properties__ = ( "id", "name", "type", @@ -52,46 +66,50 @@ class Image(BaseDomain, DomainIdentityMixin): "bound_to", "os_flavor", "os_version", + "architecture", "rapid_deploy", "created_from", "status", "protection", - "labels" + "labels", + "created", + "deprecated", ) + __slots__ = __api_properties__ - supported_fields = ("created", "deprecated") - + # 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 - + self, + 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 = created + self.created = self._parse_datetime(created) self.description = description self.image_size = image_size self.disk_size = disk_size - self.deprecated = deprecated + 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 @@ -99,6 +117,10 @@ def __init__( self.status = status +class ImageProtection(TypedDict): + delete: bool + + class CreateImageResponse(BaseDomain): """Create Image Response Domain @@ -107,15 +129,14 @@ class CreateImageResponse(BaseDomain): :param action: :class:`BoundAction ` 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 + self, + 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 5d7f7537..b9c3a5a0 100644 --- a/hcloud/isos/client.py +++ b/hcloud/isos/client.py @@ -1,70 +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") - 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] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundIso(self, response["iso"]) + + def get_list( + self, + name: str | None = None, + architecture: list[str] | None = None, + include_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 = {} - if name: - params['name'] = name + 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 + params["page"] = page if per_page is not None: - params['per_page'] = per_page + params["per_page"] = per_page - response = self._client.request(url="/isos", method="GET", params=params) - isos = [BoundIso(self, iso_data) for iso_data in response['isos']] - return self._add_meta_to_result(isos, response) + response = self._client.request(url=self._base_url, method="GET", params=params) + isos = [BoundIso(self, iso_data) for iso_data in response["isos"]] + return IsosPageResult(isos, Meta.parse_meta(response)) - 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 6ee7f015..5fd50c6e 100644 --- a/hcloud/isos/domain.py +++ b/hcloud/isos/domain.py @@ -1,6 +1,15 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin -from hcloud.helpers.descriptors import ISODateTime +from __future__ import annotations + +from datetime import datetime +from typing import Any +from warnings import warn + +from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + +__all__ = [ + "Iso", +] class Iso(BaseDomain, DomainIdentityMixin): @@ -14,30 +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. """ - deprecated = ISODateTime() - __slots__ = ( + __api_properties__ = ( "id", "name", "type", - "description" + "architecture", + "description", + "deprecation", ) - - supported_fields = ("deprecated", ) + __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 = deprecated + 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 new file mode 100644 index 00000000..1b147bf7 --- /dev/null +++ b/hcloud/load_balancer_types/__init__.py @@ -0,0 +1,15 @@ +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 new file mode 100644 index 00000000..87d44e2f --- /dev/null +++ b/hcloud/load_balancer_types/client.py @@ -0,0 +1,90 @@ +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 + + model = LoadBalancerType + + +class LoadBalancerTypesPageResult(NamedTuple): + load_balancer_types: list[BoundLoadBalancerType] + meta: Meta + + +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=f"{self._base_url}/{id}", + method="GET", + ) + return BoundLoadBalancerType(self, response["load_balancer_type"]) + + 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) + Can be used to filter Load Balancer type by their name. + :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:`BoundLoadBalancerType `], :class:`Meta `) + """ + 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(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 LoadBalancerTypesPageResult( + load_balancer_types, Meta.parse_meta(response) + ) + + 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 self._iter_pages(self.get_list, name=name) + + 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 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 new file mode 100644 index 00000000..7814a4f4 --- /dev/null +++ b/hcloud/load_balancer_types/domain.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from typing import Any + +from ..core import BaseDomain, DomainIdentityMixin +from ..deprecation import DeprecationInfo + +__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 + :param deprecation: Define if and when the Load Balancer Type is deprecated. + """ + + __api_properties__ = ( + "id", + "name", + "description", + "max_connections", + "max_services", + "max_targets", + "max_assigned_certificates", + "prices", + "deprecation", + ) + __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, + deprecation: 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 + self.deprecation = deprecation and DeprecationInfo.from_dict(deprecation) diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py new file mode 100644 index 00000000..9b07d026 --- /dev/null +++ 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 new file mode 100644 index 00000000..6dd4672e --- /dev/null +++ b/hcloud/load_balancers/client.py @@ -0,0 +1,966 @@ +from __future__ import annotations + +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 ..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, + LoadBalancer, + LoadBalancerAlgorithm, + LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, + LoadBalancerService, + LoadBalancerServiceHttp, + 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 + + model = LoadBalancer + + # 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"]) + + 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"]) + data["public_net"] = PublicNetwork( + ipv4=ipv4_address, ipv6=ipv6_network, enabled=public_net["enabled"] + ) + + private_nets = data.get("private_net") + if private_nets: + private_nets = [ + PrivateNet( + network=BoundNetwork( + client._parent.networks, + {"id": private_net["network"]}, + complete=False, + ), + ip=private_net["ip"], + ) + for private_net in private_nets + ] + data["private_net"] = private_nets + + def _load_balancer_targets( + raw_targets: list[dict[str, Any]], + ) -> list[LoadBalancerTarget]: + return [_load_balancer_target(raw_target) for raw_target in raw_targets] + + def _load_balancer_target( + raw_target: dict[str, Any], + ) -> LoadBalancerTarget: + result = LoadBalancerTarget(type=raw_target["type"]) + + if raw_target["type"] == "ip": + result.ip = LoadBalancerTargetIP( + ip=raw_target["ip"]["ip"], + ) + + elif raw_target["type"] == "server": + result.server = BoundServer( + client._parent.servers, # pylint: disable=protected-access + data=raw_target["server"], + complete=False, + ) + result.use_private_ip = raw_target["use_private_ip"] + + elif raw_target["type"] == "label_selector": + result.label_selector = LoadBalancerTargetLabelSelector( + selector=raw_target["label_selector"]["selector"] + ) + result.use_private_ip = raw_target["use_private_ip"] + + if (raw_nested_targets := raw_target.get("targets")) is not None: + result.targets = _load_balancer_targets(raw_nested_targets) + + if (raw_health_status := raw_target.get("health_status")) is not None: + result.health_status = [ + LoadBalancerTargetHealthStatus( + listen_port=item["listen_port"], + status=item["status"], + ) + for item in raw_health_status + ] + + return result + + if (raw_targets := data.get("targets")) is not None: + data["targets"] = _load_balancer_targets(raw_targets) + + services = data.get("services") + if services: + tmp_services = [] + for service in services: + tmp_service = LoadBalancerService( + protocol=service["protocol"], + listen_port=service["listen_port"], + destination_port=service["destination_port"], + proxyprotocol=service["proxyprotocol"], + ) + if service["protocol"] != "tcp": + tmp_service.http = LoadBalancerServiceHttp( + sticky_sessions=service["http"]["sticky_sessions"], + redirect_http=service["http"]["redirect_http"], + cookie_name=service["http"]["cookie_name"], + cookie_lifetime=service["http"]["cookie_lifetime"], + timeout_idle=service["http"]["timeout_idle"], + ) + tmp_service.http.certificates = [ + BoundCertificate( + client._parent.certificates, + {"id": certificate}, + complete=False, + ) + for certificate in service["http"]["certificates"] + ] + + tmp_service.health_check = LoadBalancerHealthCheck( + protocol=service["health_check"]["protocol"], + port=service["health_check"]["port"], + interval=service["health_check"]["interval"], + retries=service["health_check"]["retries"], + timeout=service["health_check"]["timeout"], + ) + if tmp_service.health_check.protocol != "tcp": + tmp_service.health_check.http = LoadBalancerHealthCheckHttp( + domain=service["health_check"]["http"]["domain"], + path=service["health_check"]["http"]["path"], + response=service["health_check"]["http"]["response"], + tls=service["health_check"]["http"]["tls"], + status_codes=service["health_check"]["http"]["status_codes"], + ) + tmp_services.append(tmp_service) + data["services"] = tmp_services + + load_balancer_type = data.get("load_balancer_type") + if load_balancer_type is not None: + data["load_balancer_type"] = BoundLoadBalancerType( + client._parent.load_balancer_types, load_balancer_type + ) + + location = data.get("location") + if location is not None: + data["location"] = BoundLocation(client._parent.locations, location) + + super().__init__(client, data, complete) + + 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) + New name to set + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundLoadBalancer ` + """ + return self._client.update(self, name=name, labels=labels) + + def delete(self) -> bool: + """Deletes a Load Balancer. + + :return: boolean + """ + return self._client.delete(self) + + 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, + ) + + 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: list[ActionStatus] | None = None, + sort: list[ActionSort] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Load Balancer. + + :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 add_service(self, service: LoadBalancerService) -> BoundAction: + """Adds a service to a Load Balancer. + + :param service: :class:`LoadBalancerService ` + The LoadBalancerService you want to add to the Load Balancer + :return: :class:`BoundAction ` + """ + return self._client.add_service(self, service=service) + + def update_service(self, service: LoadBalancerService) -> BoundAction: + """Updates a service of an Load Balancer. + + :param service: :class:`LoadBalancerService ` + The LoadBalancerService you want to update + :return: :class:`BoundAction ` + """ + return self._client.update_service(self, service=service) + + 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=service) + + 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=target) + + 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=target) + + 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=algorithm) + + def change_dns_ptr(self, ip: str, dns_ptr: str) -> BoundAction: + """Changes the hostname that will appear when getting the hostname belonging to the public IPs (IPv4 and IPv6) of this Load Balancer. + + :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) + + 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=delete) + + def attach_to_network( + self, + network: Network | BoundNetwork, + ip: str | None = None, + ip_range: str | None = None, + ) -> BoundAction: + """Attaches a Load Balancer to a Network + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param ip: str + IP to request to be assigned to this Load Balancer + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. + :return: :class:`BoundAction ` + """ + return self._client.attach_to_network( + self, + network=network, + ip=ip, + ip_range=ip_range, + ) + + 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=network) + + 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) -> 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: 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=load_balancer_type) + + +class LoadBalancersPageResult(NamedTuple): + load_balancers: list[BoundLoadBalancer] + meta: Meta + + +class LoadBalancersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/load_balancers" + + 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=f"{self._base_url}/{id}", + method="GET", + ) + return BoundLoadBalancer(self, response["load_balancer"]) + + def get_list( + self, + 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) + Can be used to filter Load Balancers by their name. + :param label_selector: str (optional) + Can be used to filter Load Balancers by labels. The response will only contain Load Balancers matching the label selector. + :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:`BoundLoadBalancer `], :class:`Meta `) + """ + 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 + + response = self._client.request(url=self._base_url, method="GET", params=params) + + load_balancers = [ + BoundLoadBalancer(self, load_balancer_data) + for load_balancer_data in response["load_balancers"] + ] + return LoadBalancersPageResult(load_balancers, Meta.parse_meta(response)) + + 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) + Can be used to filter Load Balancers by their name. + :param label_selector: str (optional) + 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 self._iter_pages(self.get_list, name=name, label_selector=label_selector) + + 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 self._get_first_by(self.get_list, name=name) + + def create( + self, + 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 + Name of the Load Balancer + :param load_balancer_type: LoadBalancerType + Type of the Load Balancer + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param location: Location + Location of the Load Balancer + :param network_zone: str + Network Zone of the Load Balancer + :param algorithm: LoadBalancerAlgorithm (optional) + The algorithm the Load Balancer is currently using + :param services: LoadBalancerService + The services the Load Balancer is currently serving + :param targets: LoadBalancerTarget + The targets the Load Balancer is currently serving + :param public_interface: bool + Enable or disable the public interface of the Load Balancer + :param network: Network + Adds the Load Balancer to a Network + :return: :class:`CreateLoadBalancerResponse ` + """ + 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: + data["public_interface"] = public_interface + if labels is not None: + data["labels"] = labels + if algorithm is not None: + data["algorithm"] = {"type": algorithm.type} + if services is not None: + data["services"] = [service.to_payload() for service in services] + if targets is not None: + 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=self._base_url, method="POST", json=data) + + return CreateLoadBalancerResponse( + load_balancer=BoundLoadBalancer(self, response["load_balancer"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + 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 ` + :param name: str (optional) + New name to set + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundLoadBalancer ` + """ + 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=f"{self._base_url}/{load_balancer.id}", + method="PUT", + json=data, + ) + return BoundLoadBalancer(self, response["load_balancer"]) + + 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=f"{self._base_url}/{load_balancer.id}", + method="DELETE", + ) + return True + + 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=f"{self._base_url}/{load_balancer.id}/metrics", + method="GET", + params=params, + ) + return GetMetricsResponse( + metrics=Metrics(**response["metrics"]), + ) + + 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, + ) + + 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: LoadBalancer | BoundLoadBalancer, + service: LoadBalancerService, + ) -> BoundAction: + """Adds a service to a Load Balancer. + + :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` + :param service: :class:`LoadBalancerService ` + The LoadBalancerService you want to add to the Load Balancer + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = service.to_payload() + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/add_service", + method="POST", + json=data, + ) + 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 ` + :param service: :class:`LoadBalancerService ` + The LoadBalancerService with updated values within for the Load Balancer + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = service.to_payload() + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/update_service", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param service: :class:`LoadBalancerService ` + The LoadBalancerService you want to delete from the Load Balancer + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"listen_port": service.listen_port} + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/delete_service", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param target: :class:`LoadBalancerTarget ` + The LoadBalancerTarget you want to add to the Load Balancer + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = target.to_payload() + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/add_target", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param target: :class:`LoadBalancerTarget ` + The LoadBalancerTarget you want to remove from the Load Balancer + :return: :class:`BoundAction ` + """ + 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=f"{self._base_url}/{load_balancer.id}/actions/remove_target", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param algorithm: :class:`LoadBalancerAlgorithm ` + The LoadBalancerSubnet you want to add to the Load Balancer + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"type": algorithm.type} + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/change_algorithm", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 + 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}/{load_balancer.id}/actions/change_dns_ptr", + method="POST", + json={"ip": ip, "dns_ptr": dns_ptr}, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param delete: boolean + If True, prevents the Load Balancer 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}/{load_balancer.id}/actions/change_protection", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def attach_to_network( + self, + load_balancer: LoadBalancer | BoundLoadBalancer, + network: Network | BoundNetwork, + ip: str | None = None, + ip_range: str | None = None, + ) -> BoundAction: + """Attach a Load Balancer to a Network. + + :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: dict[str, Any] = {"network": network.id} + if ip is not None: + data.update({"ip": ip}) + if ip_range is not None: + data.update({"ip_range": ip_range}) + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/attach_to_network", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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: dict[str, Any] = {"network": network.id} + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/detach_from_network", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + + :return: :class:`BoundAction ` + """ + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/enable_public_interface", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + + :return: :class:`BoundAction ` + """ + + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/disable_public_interface", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param load_balancer_type: :class:`BoundLoadBalancerType ` or :class:`LoadBalancerType ` + Load Balancer type the Load Balancer should migrate to + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"load_balancer_type": load_balancer_type.id_or_name} + response = self._client.request( + url=f"{self._base_url}/{load_balancer.id}/actions/change_type", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py new file mode 100644 index 00000000..7561af2a --- /dev/null +++ b/hcloud/load_balancers/domain.py @@ -0,0 +1,677 @@ +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 + """ + + __api_properties__ = ( + "protocol", + "listen_port", + "destination_port", + "proxyprotocol", + "health_check", + "http", + ) + __slots__ = __api_properties__ + + 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 + if self.http.timeout_idle is not None: + http["timeout_idle"] = self.http.timeout_idle + + http["certificates"] = [ + certificate.id for certificate in self.http.certificates or [] + ] + + 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". + :param timeout_idle: int + Idle timeout in seconds for HTTP connections. Must be between 30 and 300 seconds. + """ + + __api_properties__ = ( + "cookie_name", + "cookie_lifetime", + "certificates", + "redirect_http", + "sticky_sessions", + "timeout_idle", + ) + __slots__ = __api_properties__ + + def __init__( + self, + cookie_name: str | None = None, + cookie_lifetime: str | None = None, + certificates: list[BoundCertificate] | None = None, + redirect_http: bool | None = None, + sticky_sessions: bool | None = None, + timeout_idle: int | None = None, + ): + self.cookie_name = cookie_name + self.cookie_lifetime = cookie_lifetime + self.certificates = certificates + self.redirect_http = redirect_http + self.sticky_sessions = sticky_sessions + self.timeout_idle = timeout_idle + + +class LoadBalancerHealthCheck(BaseDomain): + """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". + :param targets: list + List of resolved label selector targets. Only present for target types "label_selector". + """ + + __api_properties__ = ( + "type", + "server", + "label_selector", + "ip", + "use_private_ip", + "health_status", + "targets", + ) + __slots__ = __api_properties__ + + def __init__( + self, + type: str | None = None, + 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, + targets: list[LoadBalancerTarget] | 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 + self.targets = targets + + 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 85f0ea0d..5a71c0f6 100644 --- a/hcloud/locations/client.py +++ b/hcloud/locations/client.py @@ -1,28 +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") - return BoundLocation(self, response['location']) + 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) @@ -33,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: @@ -41,26 +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) - locations = [BoundLocation(self, location_data) for location_data in response['locations']] - return self._add_meta_to_result(locations, response) + response = self._client.request(url=self._base_url, method="GET", params=params) + locations = [ + BoundLocation(self, location_data) + for location_data in response["locations"] + ] + 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 ef1882d7..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): @@ -19,27 +24,32 @@ class Location(BaseDomain, DomainIdentityMixin): Latitude of the city closest to the location :param longitude: float Longitude of the city closest to the location + :param network_zone: str + Name of network zone this location resides in """ - __slots__ = ( + __api_properties__ = ( "id", "name", "description", "country", "city", "latitude", - "longitude" + "longitude", + "network_zone", ) + __slots__ = __api_properties__ def __init__( - self, - id=None, - name=None, - description=None, - country=None, - city=None, - latitude=None, - longitude=None + self, + 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 @@ -48,3 +58,4 @@ def __init__( self.city = city self.latitude = latitude self.longitude = longitude + self.network_zone = network_zone 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 new file mode 100644 index 00000000..f49b9934 --- /dev/null +++ b/hcloud/networks/__init__.py @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..73b33e5d --- /dev/null +++ b/hcloud/networks/client.py @@ -0,0 +1,559 @@ +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 Network, NetworkRoute, NetworkSubnet + +if TYPE_CHECKING: + from .._client import Client + + +__all__ = [ + "BoundNetwork", + "NetworksPageResult", + "NetworksClient", +] + + +class BoundNetwork(BoundModelBase[Network], Network): + _client: NetworksClient + + model = Network + + 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] + data["subnets"] = subnets + + routes = data.get("routes", []) + if routes is not None: + routes = [NetworkRoute.from_dict(route) for route in routes] + data["routes"] = routes + + # pylint: disable=import-outside-toplevel + from ..servers import BoundServer + + servers = data.get("servers", []) + if servers is not None: + servers = [ + BoundServer(client._parent.servers, {"id": server}, complete=False) + for server in servers + ] + data["servers"] = servers + + super().__init__(client, data, complete) + + 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=name, + expose_routes_to_vswitch=expose_routes_to_vswitch, + labels=labels, + ) + + def delete(self) -> bool: + """Deletes a network. + + :return: boolean + """ + return self._client.delete(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 Network. + + :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 Network. + + :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 add_subnet(self, subnet: NetworkSubnet) -> BoundAction: + """Adds a subnet entry to a network. + + :param subnet: :class:`NetworkSubnet ` + The NetworkSubnet you want to add to the Network + :return: :class:`BoundAction ` + """ + return self._client.add_subnet(self, subnet=subnet) + + def delete_subnet(self, subnet: NetworkSubnet) -> BoundAction: + """Removes a subnet entry from a network + + :param subnet: :class:`NetworkSubnet ` + The NetworkSubnet you want to remove from the Network + :return: :class:`BoundAction ` + """ + return self._client.delete_subnet(self, subnet=subnet) + + def add_route(self, route: NetworkRoute) -> BoundAction: + """Adds a route entry to a network. + + :param route: :class:`NetworkRoute ` + The NetworkRoute you want to add to the Network + :return: :class:`BoundAction ` + """ + return self._client.add_route(self, route=route) + + def delete_route(self, route: NetworkRoute) -> BoundAction: + """Removes a route entry to a network. + + :param route: :class:`NetworkRoute ` + The NetworkRoute you want to remove from the Network + :return: :class:`BoundAction ` + """ + return self._client.delete_route(self, route=route) + + def change_ip_range(self, ip_range: str) -> BoundAction: + """Changes the IP range of a network. + + :param ip_range: str + The new prefix for the whole network. + :return: :class:`BoundAction ` + """ + return self._client.change_ip_range(self, ip_range=ip_range) + + def change_protection(self, delete: bool | None = None) -> BoundAction: + """Changes the protection configuration of a network. + + :param delete: boolean + If True, prevents the network from being deleted + :return: :class:`BoundAction ` + """ + return self._client.change_protection(self, delete=delete) + + +class NetworksPageResult(NamedTuple): + networks: list[BoundNetwork] + meta: Meta + + +class NetworksClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/networks" + + actions: ResourceActionsClient + """Networks 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) -> BoundNetwork: + """Get a specific network + + :param id: int + :return: :class:`BoundNetwork ` + """ + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundNetwork(self, response["network"]) + + def get_list( + self, + 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) + Can be used to filter networks by their name. + :param label_selector: str (optional) + Can be used to filter networks by labels. The response will only contain networks matching the label selector. + :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:`BoundNetwork `], :class:`Meta `) + """ + 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 + + response = self._client.request(url=self._base_url, method="GET", params=params) + + networks = [ + BoundNetwork(self, network_data) for network_data in response["networks"] + ] + return NetworksPageResult(networks, Meta.parse_meta(response)) + + 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) + Can be used to filter networks by their name. + :param label_selector: str (optional) + Can be used to filter networks by labels. The response will only contain networks matching the label selector. + :return: List[:class:`BoundNetwork `] + """ + return self._iter_pages(self.get_list, name=name, label_selector=label_selector) + + 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 self._get_first_by(self.get_list, name=name) + + def create( + self, + 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 + Name of the network + :param ip_range: str + IP range of the whole network which must span all included subnets and route destinations + :param subnets: List[:class:`NetworkSubnet `] + 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: dict[str, Any] = {"name": name, "ip_range": ip_range} + if subnets is not None: + data_subnets = [] + for subnet in subnets: + data_subnet: dict[str, Any] = { + "type": subnet.type, + "ip_range": subnet.ip_range, + "network_zone": subnet.network_zone, + } + 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=self._base_url, method="POST", json=data) + + return BoundNetwork(self, response["network"]) + + 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: 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=f"{self._base_url}/{network.id}", + method="PUT", + json=data, + ) + return BoundNetwork(self, response["network"]) + + def delete(self, network: Network | BoundNetwork) -> bool: + """Deletes a network. + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :return: boolean + """ + self._client.request(url=f"{self._base_url}/{network.id}", method="DELETE") + return True + + def get_actions_list( + self, + network: Network | BoundNetwork, + 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 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, + ) + + 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: Network to get the Actions for. + :param status: Filter the Actions by status. + :param sort: Sort Actions by field and direction. + """ + return self._iter_pages( + self.get_actions_list, + network, + status=status, + sort=sort, + ) + + def add_subnet( + self, + network: Network | BoundNetwork, + subnet: NetworkSubnet, + ) -> BoundAction: + """Adds a subnet entry to a network. + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param subnet: :class:`NetworkSubnet ` + The NetworkSubnet you want to add to the Network + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = { + "type": subnet.type, + "network_zone": subnet.network_zone, + } + if subnet.ip_range is not None: + data["ip_range"] = subnet.ip_range + if subnet.vswitch_id is not None: + data["vswitch_id"] = subnet.vswitch_id + + response = self._client.request( + url=f"{self._base_url}/{network.id}/actions/add_subnet", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def delete_subnet( + self, + network: Network | BoundNetwork, + subnet: NetworkSubnet, + ) -> BoundAction: + """Removes a subnet entry from a network + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param subnet: :class:`NetworkSubnet ` + The NetworkSubnet you want to remove from the Network + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"ip_range": subnet.ip_range} + + response = self._client.request( + url=f"{self._base_url}/{network.id}/actions/delete_subnet", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def add_route( + self, + network: Network | BoundNetwork, + route: NetworkRoute, + ) -> BoundAction: + """Adds a route entry to a network. + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param route: :class:`NetworkRoute ` + The NetworkRoute you want to add to the Network + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = { + "destination": route.destination, + "gateway": route.gateway, + } + + response = self._client.request( + url=f"{self._base_url}/{network.id}/actions/add_route", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def delete_route( + self, + network: Network | BoundNetwork, + route: NetworkRoute, + ) -> BoundAction: + """Removes a route entry to a network. + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param route: :class:`NetworkRoute ` + The NetworkRoute you want to remove from the Network + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = { + "destination": route.destination, + "gateway": route.gateway, + } + + response = self._client.request( + url=f"{self._base_url}/{network.id}/actions/delete_route", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param ip_range: str + The new prefix for the whole network. + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"ip_range": ip_range} + + response = self._client.request( + url=f"{self._base_url}/{network.id}/actions/change_ip_range", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param delete: boolean + If True, prevents the network 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}/{network.id}/actions/change_protection", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/networks/domain.py b/hcloud/networks/domain.py new file mode 100644 index 00000000..80c3badc --- /dev/null +++ b/hcloud/networks/domain.py @@ -0,0 +1,182 @@ +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 ..servers import BoundServer + from .client import BoundNetwork + +__all__ = [ + "Network", + "NetworkProtection", + "NetworkSubnet", + "NetworkRoute", + "CreateNetworkResponse", +] + + +class Network(BaseDomain, DomainIdentityMixin): + """Network Domain + + :param id: int + ID of the network + :param name: str + Name of the network + :param ip_range: str + IPv4 prefix of the whole network + :param subnets: List[:class:`NetworkSubnet `] + 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 + Protection configuration for the network + :param labels: dict + User-defined labels (key-value pairs) + """ + + __api_properties__ = ( + "id", + "name", + "ip_range", + "subnets", + "routes", + "expose_routes_to_vswitch", + "servers", + "protection", + "labels", + "created", + ) + __slots__ = __api_properties__ + + def __init__( + self, + 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 = 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 + + :param type: str + Type of sub network. + :param ip_range: str + Range to allocate IPs from. + :param network_zone: str + Name of network zone. + :param gateway: str + Gateway for the route. + :param vswitch_id: int + ID of the vSwitch. + """ + + @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" + """ + Used to connect cloud servers and load balancers. + """ + TYPE_VSWITCH = "vswitch" + """ + 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: 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 + self.network_zone = network_zone + self.gateway = gateway + self.vswitch_id = vswitch_id + + +class NetworkRoute(BaseDomain): + """Network Route Domain + + :param destination: str + Destination network or host of this route. + :param gateway: str + Gateway for the route. + """ + + __api_properties__ = ("destination", "gateway") + __slots__ = __api_properties__ + + def __init__(self, destination: str, gateway: str): + self.destination = destination + self.gateway = gateway + + +class CreateNetworkResponse(BaseDomain): + """Create Network Response Domain + + :param network: :class:`BoundNetwork ` + The network which was created + :param action: :class:`BoundAction ` + The Action which shows the progress of the network Creation + """ + + __api_properties__ = ("network", "action") + __slots__ = __api_properties__ + + def __init__( + self, + network: BoundNetwork, + action: BoundAction, + ): + self.network = network + self.action = action diff --git a/hcloud/placement_groups/__init__.py b/hcloud/placement_groups/__init__.py new file mode 100644 index 00000000..ab82da5a --- /dev/null +++ b/hcloud/placement_groups/__init__.py @@ -0,0 +1,16 @@ +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 new file mode 100644 index 00000000..041118d8 --- /dev/null +++ b/hcloud/placement_groups/client.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +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 + + model = PlacementGroup + + 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) + User-defined labels (key-value pairs) + :param name: str, (optional) + New Name to set + :return: :class:`BoundPlacementGroup ` + """ + return self._client.update(self, labels=labels, name=name) + + def delete(self) -> bool: + """Deletes a Placement Group + + :return: boolean + """ + return self._client.delete(self) + + +class PlacementGroupsPageResult(NamedTuple): + placement_groups: list[BoundPlacementGroup] + meta: Meta + + +class PlacementGroupsClient(ResourceClientBase): + _base_url = "/placement_groups" + + 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=f"{self._base_url}/{id}", + method="GET", + ) + return BoundPlacementGroup(self, response["placement_group"]) + + def get_list( + self, + 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) + Can be used to filter Placement Groups by labels. The response will only contain Placement Groups matching the label selector 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 Placement Groups by their name. + :param sort: List[str] (optional) + Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) + :return: (List[:class:`BoundPlacementGroup `], :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 sort is not None: + params["sort"] = sort + if type is not None: + params["type"] = type + 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 PlacementGroupsPageResult(placement_groups, Meta.parse_meta(response)) + + 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) + Can be used to filter Placement Groups by labels. The response will only contain Placement Groups matching the label selector values. + :param name: str (optional) + Can be used to filter Placement Groups by their name. + :param sort: List[str] (optional) + Choices: id name created (You can add one of ":asc", ":desc" to modify sort order. ( ":asc" is default)) + :return: List[:class:`BoundPlacementGroup `] + """ + return self._iter_pages( + self.get_list, + label_selector=label_selector, + name=name, + sort=sort, + ) + + 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 self._get_first_by(self.get_list, name=name) + + def create( + self, + name: str, + type: str, + labels: dict[str, str] | None = None, + ) -> CreatePlacementGroupResponse: + """Creates a new Placement Group. + + :param name: str + Placement Group Name + :param type: str + Type of the Placement Group + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + + :return: :class:`CreatePlacementGroupResponse ` + """ + data: dict[str, Any] = {"name": name, "type": type} + 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 = CreatePlacementGroupResponse( + placement_group=BoundPlacementGroup(self, response["placement_group"]), + action=action, + ) + return result + + 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 ` + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :param name: str (optional) + New name to set + :return: :class:`BoundPlacementGroup ` + """ + + 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=f"{self._base_url}/{placement_group.id}", + method="PUT", + json=data, + ) + return BoundPlacementGroup(self, response["placement_group"]) + + 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=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 new file mode 100644 index 00000000..1dc9b9dd --- /dev/null +++ b/hcloud/placement_groups/domain.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundPlacementGroup + +__all__ = [ + "PlacementGroup", + "CreatePlacementGroupResponse", +] + + +class PlacementGroup(BaseDomain, DomainIdentityMixin): + """Placement Group Domain + + :param id: int + ID of the Placement Group + :param name: str + Name of the Placement Group + :param labels: dict + User-defined labels (key-value pairs) + :param servers: List[ int ] + List of server IDs assigned to the Placement Group + :param type: str + Type of the Placement Group + :param created: datetime + Point in time when the image was 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 + """ + TYPE_SPREAD = "spread" + + def __init__( + 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 = self._parse_datetime(created) + + +class CreatePlacementGroupResponse(BaseDomain): + """Create Placement Group Response Domain + + :param placement_group: :class:`BoundPlacementGroup ` + The Placement Group which was created + :param action: :class:`BoundAction ` + The Action which shows the progress of the Placement Group Creation + """ + + __api_properties__ = ("placement_group", "action") + __slots__ = __api_properties__ + + def __init__( + self, + 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..08efdf34 --- /dev/null +++ b/hcloud/primary_ips/client.py @@ -0,0 +1,484 @@ +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 ..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 ..locations import BoundLocation + + 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, + location: Location | BoundLocation | None = None, + assignee_type: str | None = None, + 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 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, + "auto_delete": auto_delete, + } + + if location is not None: + data["location"] = location.id_or_name + if assignee_id is not None: + data["assignee_id"] = assignee_id + if assignee_type is None: + assignee_type = "server" + warnings.warn( + "The 'assignee_type' argument will no longer default to 'server' " + "and will be required together with the 'assignee_id' argument. " + "Please explicitly set the 'assignee_type' argument.", + DeprecationWarning, + stacklevel=2, + ) + data["assignee_type"] = assignee_type + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="POST", + url=self._base_url, + json=data, + ) + + 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..2e964498 --- /dev/null +++ b/hcloud/primary_ips/domain.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from ..locations import BoundLocation + from ..rdns import DNSPtr + from .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 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. + """ + + __api_properties__ = ( + "id", + "ip", + "type", + "dns_ptr", + "location", + "blocked", + "protection", + "labels", + "created", + "name", + "assignee_id", + "assignee_type", + "auto_delete", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + type: str | None = None, + ip: str | None = None, + dns_ptr: list[DNSPtr] | 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.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 + + +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 c2548579..98da70af 100644 --- a/hcloud/server_types/client.py +++ b/hcloud/server_types/client.py @@ -1,26 +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") - return BoundServerType(self, response['server_type']) + 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) @@ -31,34 +74,35 @@ 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 + params["name"] = name if page is not None: - params['page'] = page + params["page"] = page if per_page is not None: - params['per_page'] = per_page + params["per_page"] = per_page - response = self._client.request(url="/server_types", 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) + 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 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 9f016696..cafa873a 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,50 +22,192 @@ 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. 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" + "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 - + 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. + :param available: Whether the Server Type is currently available in this Location. + :param recommended: Whether the Server Type is currently recommended in this Location. + """ + + __api_properties__ = ( + "location", + "deprecation", + "available", + "recommended", + ) + __slots__ = __api_properties__ + + def __init__( + self, + *, + location: BoundLocation, + deprecation: dict[str, Any] | None, + available: bool | None, + recommended: bool | None, + ): + self.location = location + self.deprecation = ( + DeprecationInfo.from_dict(deprecation) if deprecation is not None else None + ) + self.available = available + self.recommended = recommended diff --git a/hcloud/servers/__init__.py b/hcloud/servers/__init__.py index 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 70eb69b3..68cbb9ce 100644 --- a/hcloud/servers/client.py +++ b/hcloud/servers/client.py @@ -1,153 +1,314 @@ -# -*- 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.floating_ips.client import BoundFloatingIP -from hcloud.isos.client import BoundIso -from hcloud.servers.domain import Server, CreateServerResponse, ResetPasswordResponse, EnableRescueResponse, \ - RequestConsoleResponse, PublicNetwork, IPv4Address, IPv6Network -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 - - -class BoundServer(BoundModelBase): - model = Server - - def __init__(self, client, data, complete=True): +from __future__ import annotations + +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 ..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, + EnableRescueResponse, + GetMetricsResponse, + IPv4Address, + IPv6Network, + MetricsType, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + RebuildResponse, + RequestConsoleResponse, + ResetPasswordResponse, + Server, +) + +if TYPE_CHECKING: + from .._client import Client + 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 - datacenter = data.get('datacenter') - if datacenter is not None: - data['datacenter'] = BoundDatacenter(client._client.datacenters, datacenter) + model = Server - volumes = data.get('volumes', []) + # pylint: disable=too-many-locals + def __init__( + self, + client: ServersClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("location") + if raw: + data["location"] = BoundLocation(client._parent.locations, raw) + + volumes = data.get("volumes", []) if volumes: - volumes = [BoundVolume(client._client.volumes, {"id": volume}, complete=False) for volume in volumes] - data['volumes'] = volumes + volumes = [ + 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) + data["server_type"] = BoundServerType( + client._parent.server_types, server_type + ) public_net = data.get("public_net") if public_net: - ipv4_address = IPv4Address(**public_net['ipv4']) - ipv6_network = IPv6Network(**public_net['ipv6']) - floating_ips = [BoundFloatingIP(client._client.floating_ips, {"id": floating_ip}, complete=False) for - floating_ip in public_net['floating_ips']] - data['public_net'] = PublicNetwork(ipv4=ipv4_address, ipv6=ipv6_network, floating_ips=floating_ips) - - super(BoundServer, 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]) -> PageResults[List[BoundAction, Meta]] - """Returns all action objects for a server. + 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._parent.floating_ips, {"id": floating_ip}, complete=False + ) + for floating_ip in public_net["floating_ips"] + ] + firewalls = [ + PublicNetworkFirewall( + BoundFirewall( + client._parent.firewalls, {"id": firewall["id"]}, complete=False + ), + status=firewall["status"], + ) + for firewall in public_net.get("firewalls", []) + ] + 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._parent.networks, + {"id": private_net["network"]}, + complete=False, + ), + ip=private_net["ip"], + alias_ips=private_net["alias_ips"], + mac_address=private_net["mac_address"], + ) + for private_net in private_nets + ] + data["private_net"] = private_nets + + placement_group = data.get("placement_group") + if placement_group: + placement_group = BoundPlacementGroup( + client._parent.placement_groups, placement_group + ) + data["placement_group"] = placement_group + + 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 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) - New name to set - :param labels: Dict[str, str] (optional) - User-defined labels (key-value pairs) - :return: :class:`BoundServer ` - """ - return self._client.update(self, name, labels) + :param name: str (optional) + New name to set + :param labels: Dict[str, str] (optional) + User-defined labels (key-value pairs) + :return: :class:`BoundServer ` + """ + 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 - """ Resets the root password. Only works for Linux systems that are running the qemu guest agent. + 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 @@ -159,16 +320,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]]) -> BoundAction + 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) @@ -178,21 +342,31 @@ def create_image(self, description=None, type=None, labels=None): Choices: snapshot, backup :param labels: Dict[str, str] User-defined labels (key-value pairs) - :return: :class:`BoundAction ` + :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 ` @@ -201,43 +375,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 @@ -246,10 +417,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 ` @@ -259,38 +433,122 @@ 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: 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 ` + :param ip: str + 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=network, + ip=ip, + alias_ips=alias_ips, + ip_range=ip_range, + ) + + def detach_from_network(self, network: Network | BoundNetwork) -> BoundAction: + """Detaches a server from a network. + + :param network: :class:`BoundNetwork ` or :class:`Network ` + :return: :class:`BoundAction ` + """ + return self._client.detach_from_network(self, network=network) + + 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 ` + :param alias_ips: List[str] + New alias IPs to set for this server. + :return: :class:`BoundAction ` + """ + return self._client.change_alias_ips(self, network=network, alias_ips=alias_ips) + + 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=placement_group + ) + + def remove_from_placement_group(self) -> BoundAction: + """Removes a server from a placement group. + + :return: :class:`BoundAction ` + """ + return self._client.remove_from_placement_group(self) + + +class ServersPageResult(NamedTuple): + servers: list[BoundServer] + meta: Meta + + +class ServersClient( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/servers" + + actions: ResourceActionsClient + """Servers scoped actions client + + :type: :class:`ResourceActionsClient ` + """ -class ServersClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = 'servers' + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) - def get_by_id(self, id): - # type: (int) -> BoundServer + 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") - 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] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundServer(self, response["server"]) + + def get_list( + self, + 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) @@ -305,25 +563,31 @@ def get_list(self, Specifies how many results are returned by page :return: (List[:class:`BoundServer `], :class:`Meta `) """ - params = {} - if name: - params['name'] = name - if label_selector: - params['label_selector'] = label_selector - if status: + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if label_selector is not None: + params["label_selector"] = label_selector + if status is not None: params["status"] = status - if page: - params['page'] = page - if per_page: - params['per_page'] = per_page + if page is not None: + params["page"] = page + 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) + ass_servers = [ + BoundServer(self, server_data) for server_data in response["servers"] + ] + 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) @@ -334,32 +598,40 @@ 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) - - 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]] - 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] - ): - # type: (...) -> CreateServerResponse + return self._get_first_by(self.get_list, name=name) + + # pylint: disable=too-many-branches,too-many-locals + def create( + self, + 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, + 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 @@ -372,95 +644,124 @@ def create(self, SSH keys which should be injected into the server at creation time :param volumes: List[:class:`BoundVolume ` or :class:`Volume `] (optional) Volumes which should be attached to the server at the creation time. Volumes must be in the same location. + :param networks: List[:class:`BoundNetwork ` or :class:`Network `] (optional) + Networks which should be attached to the server at the creation time. :param user_data: str (optional) Cloud-Init user data to use during server creation. This field is limited to 32KiB. :param labels: Dict[str,str] (optional) User-defined labels (key-value pairs) :param location: :class:`BoundLocation ` or :class:`Location ` - :param datacenter: :class:`BoundDatacenter ` or :class:`Datacenter ` :param start_after_create: boolean (optional) Start Server right after creation. Defaults to True. :param automount: boolean (optional) 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 = { - 'name': name, - 'server_type': server_type.id_or_name, + data: dict[str, Any] = { + "name": name, + "server_type": server_type.id_or_name, "start_after_create": start_after_create, - "image": image.id_or_name + "image": image.id_or_name, } if location is not None: - data['location'] = location.id_or_name - if datacenter is not None: - data['datacenter'] = datacenter.id_or_name + data["location"] = location.id_or_name if ssh_keys is not None: - data['ssh_keys'] = [ssh_key.id_or_name for ssh_key in ssh_keys] + data["ssh_keys"] = [ssh_key.id_or_name for ssh_key in ssh_keys] if volumes is not None: - data['volumes'] = [volume.id for volume in volumes] + data["volumes"] = [volume.id for volume in volumes] + if networks is not None: + data["networks"] = [network.id for network in networks] + if firewalls is not None: + data["firewalls"] = [{"firewall": firewall.id} for firewall in firewalls] if user_data is not None: - data['user_data'] = user_data + data["user_data"] = user_data if labels is not None: - data['labels'] = labels + data["labels"] = labels if automount is not None: data["automount"] = automount - - response = self._client.request(url="/servers", method="POST", json=data) + if placement_group is not None: + data["placement_group"] = placement_group.id + + 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']), - next_actions=[BoundAction(self._client.actions, action) for action in response['next_actions']], - root_password=response['root_password'] + server=BoundServer(self, response["server"]), + action=BoundAction(self._parent.actions, response["action"]), + next_actions=[ + BoundAction(self._parent.actions, action) + for action in response["next_actions"] + ], + root_password=response["root_password"], ) 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 `) + def get_actions_list( + 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) - actions = [BoundAction(self._client.actions, action_data) for action_data in response['actions']] - return add_meta_to_result(actions, response, 'actions') + :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, + ) - 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 ` @@ -470,93 +771,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), method="PUT", json=data) - return BoundServer(self, response['server']) + response = self._client.request( + url=f"{self._base_url}/{server.id}", + method="PUT", + json=data, + ) + return BoundServer(self, response["server"]) + + 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 - def delete(self, server): - # type: (Server) -> BoundAction + 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") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}", method="DELETE" + ) + return BoundAction(self._parent.actions, response["action"]) - def power_off(self, server): - # 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/poweroff", + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/poweron", + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/reboot", + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/reset", + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/shutdown", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) - def reset_password(self, server): - # type: (servers.domain.Server) -> ResetPasswordResponse - """ Resets the root password. Only works for Linux systems that are running the qemu guest agent. + 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), - method="POST") - return ResetPasswordResponse(action=BoundAction(self._client.actions, response['action']), - root_password=response['root_password']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/reset_password", + method="POST", + ) + return ResetPasswordResponse( + 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 ` @@ -566,16 +924,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 = { + data: dict[str, Any] = { "server_type": server_type.id_or_name, - "upgrade_disk": upgrade_disk + "upgrade_disk": upgrade_disk, } - response = self._client.request(url="/servers/{server_id}/actions/change_type".format(server_id=server.id), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) - - def enable_rescue(self, server, type=None, ssh_keys=None): - # type: (servers.domain.Server, str, Optional[List[str]]) -> EnableRescueResponse + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/change_type", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` @@ -586,30 +951,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), - method="POST", json=data) - return EnableRescueResponse(action=BoundAction(self._client.actions, response['action']), - root_password=response['root_password']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/enable_rescue", + method="POST", + json=data, + ) + return EnableRescueResponse( + 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), - method="POST") - return BoundAction(self._client.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 + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/disable_rescue", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` @@ -620,9 +994,9 @@ def create_image(self, server, description=None, type=None, labels=None): Choices: snapshot, backup :param labels: Dict[str, str] User-defined labels (key-value pairs) - :return: :class:`BoundAction ` + :return: :class:`CreateImageResponse ` """ - data = {} + data: dict[str, Any] = {} if description is not None: data.update({"description": description}) @@ -630,78 +1004,108 @@ def create_image(self, server, description=None, type=None, labels=None): data.update({"type": type}) if labels is not None: - data.update({"type": labels}) + data.update({"labels": labels}) - response = self._client.request(url="/servers/{server_id}/actions/create_image".format(server_id=server.id), - method="POST", json=data) - return CreateImageResponse(action=BoundAction(self._client.actions, response['action']), - image=BoundImage(self._client.images, response['image'])) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/create_image", + method="POST", + json=data, + ) + return CreateImageResponse( + 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 - } - response = self._client.request(url="/servers/{server_id}/actions/rebuild".format(server_id=server.id), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + data: dict[str, Any] = {"image": image.id_or_name} + if user_data is not None: + data["user_data"] = user_data + + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/rebuild", + method="POST", + json=data, + ) + + return RebuildResponse( + action=BoundAction(self._parent.actions, response["action"]), + root_password=response.get("root_password"), + ) - def enable_backup(self, server): - # type: (servers.domain.Server) -> actions.domainAction + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/enable_backup", + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/disable_backup", + method="POST", + ) + 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 - } - response = self._client.request(url="/servers/{server_id}/actions/attach_iso".format(server_id=server.id), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + data: dict[str, Any] = {"iso": iso.id_or_name} + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/attach_iso", + method="POST", + json=data, + ) + 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), - method="POST") - return BoundAction(self._client.actions, response['action']) - - def change_dns_ptr(self, server, ip, dns_ptr): - # type: (servers.domain.Server, str, str) -> actions.domainAction + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/detach_iso", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` @@ -711,16 +1115,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 - } - response = self._client.request(url="/servers/{server_id}/actions/change_dns_ptr".format(server_id=server.id), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) - - def change_protection(self, server, delete=None, rebuild=None): - # type: (servers.domain.Server, Optional[bool], Optional[bool]) -> actions.domainAction + data: dict[str, Any] = {"ip": ip, "dns_ptr": dns_ptr} + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/change_dns_ptr", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` @@ -728,26 +1136,140 @@ def change_protection(self, server, delete=None, rebuild=None): If true, prevents the server from being deleted (currently delete and rebuild attribute needs to have the same value) :param rebuild: boolean If true, prevents the server from being rebuilt (currently delete and rebuild attribute needs to have the same value) - :return: :class:`BoundAction ` + :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), method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + url=f"{self._base_url}/{server.id}/actions/change_protection", + method="POST", + json=data, + ) + 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), - method="POST") - return RequestConsoleResponse(action=BoundAction(self._client.actions, response['action']), - wss_url=response['wss_url'], password=response['password']) + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/request_console", + method="POST", + ) + return RequestConsoleResponse( + action=BoundAction(self._parent.actions, response["action"]), + wss_url=response["wss_url"], + password=response["password"], + ) + + 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 ` + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param ip: str + IP to request to be assigned to this server + :param alias_ips: List[str] + New alias IPs to set for this server. + :param ip_range: str + IP range in CIDR block notation of the subnet to attach to. + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"network": network.id} + 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=f"{self._base_url}/{server.id}/actions/attach_to_network", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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: dict[str, Any] = {"network": network.id} + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/detach_from_network", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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 ` + :param network: :class:`BoundNetwork ` or :class:`Network ` + :param alias_ips: List[str] + New alias IPs to set for this server. + :return: :class:`BoundAction ` + """ + data: dict[str, Any] = {"network": network.id, "alias_ips": alias_ips} + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/change_alias_ips", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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: dict[str, Any] = {"placement_group": placement_group.id} + response = self._client.request( + url=f"{self._base_url}/{server.id}/actions/add_to_placement_group", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + 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=f"{self._base_url}/{server.id}/actions/remove_from_placement_group", + method="POST", + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/servers/domain.py b/hcloud/servers/domain.py index db8707b6..32ea4506 100644 --- a/hcloud/servers/domain.py +++ b/hcloud/servers/domain.py @@ -1,10 +1,46 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain - -from hcloud.helpers.descriptors import ISODateTime - - -class Server(BaseDomain): +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, TypedDict + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + 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 @@ -18,7 +54,7 @@ class Server(BaseDomain): :param public_net: :class:`PublicNetwork ` Public network information. :param server_type: :class:`BoundServerType ` - :param datacenter: :class:`BoundDatacenter ` + :param location: :class:`BoundLocation ` :param image: :class:`BoundImage `, None :param iso: :class:`BoundIso `, None :param rescue_enabled: bool @@ -33,13 +69,18 @@ class Server(BaseDomain): Inbound Traffic for the current billing period in bytes :param included_traffic: int Free Traffic for the current billing period in bytes + :param primary_disk_size: int + Size of the primary Disk :param protection: dict Protection configuration for the server :param labels: dict 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. """ + STATUS_RUNNING = "running" """Server Status running""" STATUS_INIT = "initializing" @@ -58,13 +99,14 @@ class Server(BaseDomain): """Server Status rebuilding""" STATUS_UNKNOWN = "unknown" """Server Status unknown""" - __slots__ = ( + + __api_properties__ = ( "id", "name", "status", "public_net", "server_type", - "datacenter", + "location", "image", "iso", "rescue_enabled", @@ -76,40 +118,45 @@ class Server(BaseDomain): "protection", "labels", "volumes", + "private_net", + "created", + "primary_disk_size", + "placement_group", ) + __slots__ = __api_properties__ - created = ISODateTime() - - supported_fields = ("created",) - + # 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, + self, + id: int, + name: str | None = None, + status: str | None = None, + created: str | None = None, + public_net: PublicNetwork | None = None, + server_type: BoundServerType | 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 = created + 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 @@ -121,12 +168,30 @@ def __init__( self.protection = protection self.labels = labels self.volumes = volumes + self.private_net = private_net + self.primary_disk_size = primary_disk_size + self.placement_group = placement_group + + def private_net_for(self, network: BoundNetwork | Network) -> PrivateNet | None: + """ + Returns the server's network attachment information in the given Network, + and None if no attachment was found. + """ + for o in self.private_net or []: + if o.network.id == network.id: + return o + return None + + +class ServerProtection(TypedDict): + rebuild: bool + delete: bool class CreateServerResponse(BaseDomain): """Create Server Response Domain - :param action: :class:`BoundServer ` + :param server: :class:`BoundServer ` The created server :param action: :class:`BoundAction ` Shows the progress of the server creation @@ -135,19 +200,16 @@ class CreateServerResponse(BaseDomain): :param root_password: str, None 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 + self, + server: BoundServer, + action: BoundAction, + next_actions: list[BoundAction], + root_password: str | None, ): self.server = server self.action = action @@ -163,15 +225,14 @@ class ResetPasswordResponse(BaseDomain): :param root_password: str 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 + self, + action: BoundAction, + root_password: str, ): self.action = action self.root_password = root_password @@ -185,15 +246,14 @@ class EnableRescueResponse(BaseDomain): :param root_password: str 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 + self, + action: BoundAction, + root_password: str, ): self.action = action self.root_password = root_password @@ -209,44 +269,100 @@ class RequestConsoleResponse(BaseDomain): :param password: str 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 + self, + 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__ = ( + + __api_properties__ = ( "ipv4", "ipv6", - "floating_ips" + "floating_ips", + "firewalls", + "primary_ipv4", + "primary_ipv6", ) + __slots__ = __api_properties__ - def __init__(self, - ipv4, # type: IPv4Address - ipv6, # type: IPv6Network - floating_ips, # type: List[BoundFloatingIP] - ): + def __init__( + self, + 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): + """Public Network Domain + + :param firewall: :class:`BoundFirewall ` + :param status: str + """ + + __api_properties__ = ("firewall", "status") + __slots__ = __api_properties__ + + STATUS_APPLIED = "applied" + """Public Network Firewall Status applied""" + STATUS_PENDING = "pending" + """Public Network Firewall Status pending""" + + def __init__( + self, + firewall: BoundFirewall, + status: str, + ): + self.firewall = firewall + self.status = status class IPv4Address(BaseDomain): @@ -259,17 +375,16 @@ class IPv4Address(BaseDomain): :param dns_ptr: str DNS PTR for the ip """ - __slots__ = ( - "ip", - "blocked", - "dns_ptr" - ) - def __init__(self, - ip, # type: str - blocked, # type: bool - dns_ptr, # type: str - ): + __api_properties__ = ("ip", "blocked", "dns_ptr") + __slots__ = __api_properties__ + + def __init__( + self, + ip: str, + blocked: bool, + dns_ptr: str, + ): self.ip = ip self.blocked = blocked self.dns_ptr = dns_ptr @@ -289,22 +404,96 @@ class IPv6Network(BaseDomain): :param network_mask: str The network mask """ - __slots__ = ( - "ip", - "blocked", - "dns_ptr", - "network", - "network_mask" - ) - def __init__(self, - ip, # type: str - blocked, # type: bool - dns_ptr, # type: list - ): + __api_properties__ = ("ip", "blocked", "dns_ptr", "network", "network_mask") + __slots__ = __api_properties__ + + def __init__( + self, + ip: str, + blocked: bool, + dns_ptr: list[DNSPtr], + ): self.ip = ip self.blocked = blocked self.dns_ptr = dns_ptr ip_parts = self.ip.split("/") # 2001:db8::/64 to 2001:db8:: and 64 self.network = ip_parts[0] self.network_mask = ip_parts[1] + + +class PrivateNet(BaseDomain): + """PrivateNet Domain + + :param network: :class:`BoundNetwork ` + The network the server is attached to + :param ip: str + The main IP Address of the server in the Network + :param alias_ips: List[str] + The alias ips for a server + :param mac_address: str + The mac address of the interface on the server + """ + + __api_properties__ = ("network", "ip", "alias_ips", "mac_address") + __slots__ = __api_properties__ + + def __init__( + self, + 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 5ae8be55..d88800a1 100644 --- a/hcloud/ssh_keys/client.py +++ b/hcloud/ssh_keys/client.py @@ -1,53 +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: 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 + - def get_by_id(self, id): - # type: (int) -> BoundSSHKey +class SSHKeysClient(ResourceClientBase): + _base_url = "/ssh_keys" + + 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") - 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] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundSSHKey(self, response["ssh_key"]) + + def get_list( + self, + 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) @@ -62,25 +78,31 @@ def get_list(self, Specifies how many results are returned by page :return: (List[:class:`BoundSSHKey `], :class:`Meta `) """ - params = {} - if name: - params['name'] = name - if fingerprint: - params['fingerprint'] = fingerprint - if label_selector: - params['label_selector'] = label_selector - if page: - params['page'] = page - if per_page: - params['per_page'] = per_page - - response = self._client.request(url="/ssh_keys", method="GET", params=params) - - ass_ssh_keys = [BoundSSHKey(self, server_data) for server_data in response['ssh_keys']] - return self._add_meta_to_result(ass_ssh_keys, response) - - def get_all(self, name=None, fingerprint=None, label_selector=None): - # type: (Optional[str], Optional[str], Optional[str]) -> List[BoundSSHKey] + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if fingerprint is not None: + params["fingerprint"] = fingerprint + 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 + + response = self._client.request(url=self._base_url, method="GET", params=params) + + ssh_keys = [ + BoundSSHKey(self, server_data) for server_data in response["ssh_keys"] + ] + return SSHKeysPageResult(ssh_keys, Meta.parse_meta(response)) + + 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) @@ -91,32 +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) - - def get_by_name(self, name): - # type: (str) -> SSHKeysClient + return self._iter_pages( + self.get_list, + name=name, + fingerprint=fingerprint, + label_selector=label_selector, + ) + + 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 - - def create(self, name, public_key, labels=None): - # type: (str, str, Optional[Dict[str, str]]) -> BoundSSHKey + return self._get_first_by(self.get_list, fingerprint=fingerprint) + + 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 @@ -126,17 +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) - return BoundSSHKey(self, response['ssh_key']) - - def update(self, ssh_key, name=None, labels=None): - # type: (SSHKey, Optional[str], Optional[Dict[str, str]]) -> BoundSSHKey + data["labels"] = labels + response = self._client.request(url=self._base_url, method="POST", json=data) + return BoundSSHKey(self, response["ssh_key"]) + + 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 ` @@ -146,22 +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 + 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), 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") + data["labels"] = labels + response = self._client.request( + url=f"{self._base_url}/{ssh_key.id}", + method="PUT", + json=data, + ) + return BoundSSHKey(self, response["ssh_key"]) + + 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 54eaa164..c3fc7c60 100644 --- a/hcloud/ssh_keys/domain.py +++ b/hcloud/ssh_keys/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__ = [ + "SSHKey", +] class SSHKey(BaseDomain, DomainIdentityMixin): @@ -15,25 +20,32 @@ class SSHKey(BaseDomain, DomainIdentityMixin): Public Key :param labels: Dict User-defined labels (key-value pairs) + :param created: datetime + Point in time when the SSH Key was created """ - __slots__ = ( + + __api_properties__ = ( "id", "name", "fingerprint", "public_key", - "labels" + "labels", + "created", ) + __slots__ = __api_properties__ def __init__( self, - id=None, - name=None, - fingerprint=None, - public_key=None, - labels=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 = 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 1b8c2030..a4155cf2 100644 --- a/hcloud/volumes/client.py +++ b/hcloud/volumes/client.py @@ -1,57 +1,95 @@ -# -*- 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) + + # pylint: disable=import-outside-toplevel + from ..servers import BoundServer - from hcloud.servers.client import BoundServer server = data.get("server") if server is not None: - data['server'] = BoundServer(client._client.servers, {"id": server}, complete=False) - super(BoundVolume, self).__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. + data["server"] = BoundServer( + client._parent.servers, {"id": server}, complete=False + ) + 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 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 - """ Updates the volume properties. + def update( + self, + name: str | None = None, + labels: dict[str, str] | None = None, + ) -> BoundVolume: + """Updates the volume properties. :param name: str (optional) New volume name @@ -59,70 +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( + ResourceClientBaseActionsMixin, + ResourceClientBase, +): + _base_url = "/volumes" + + actions: ResourceActionsClient + """Volumes scoped actions client + :type: :class:`ResourceActionsClient ` + """ -class VolumesClient(ClientEntityBase, GetEntityByNameMixin): - results_list_attribute_name = 'volumes' + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) - def get_by_id(self, id): - # type: (int) -> volumes.client.BoundVolume + 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") - 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] + response = self._client.request(url=f"{self._base_url}/{id}", method="GET") + return BoundVolume(self, response["volume"]) + + def get_list( + 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) @@ -137,24 +197,29 @@ def get_list(self, name=None, label_selector=None, page=None, per_page=None, sta 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: - params['label_selector'] = label_selector - if status: + params["name"] = name + if label_selector is not None: + params["label_selector"] = label_selector + if status is not None: params["status"] = status if page is not None: - params['page'] = page + params["page"] = page if per_page is not None: - params['per_page'] = per_page - - response = self._client.request(url="/volumes", method="GET", params=params) - volumes = [BoundVolume(self, volume_data) for volume_data in response['volumes']] - return self._add_meta_to_result(volumes, response) + params["per_page"] = per_page - def get_all(self, label_selector=None, status=None): - # type: (Optional[str], Optional[List[str]]) -> List[BoundVolume] + response = self._client.request(url=self._base_url, method="GET", params=params) + volumes = [ + BoundVolume(self, volume_data) for volume_data in response["volumes"] + ] + return VolumesPageResult(volumes, Meta.parse_meta(response)) + + 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: @@ -163,28 +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) - - 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 + return self._get_first_by(self.get_list, name=name) + + def create( + self, + 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 @@ -205,80 +273,86 @@ def create(self, 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 + data["labels"] = labels if location is not None: - data['location'] = location.id_or_name + data["location"] = location.id_or_name if server is not None: - data['server'] = server.id + data["server"] = server.id if automount is not None: - data['automount'] = automount + data["automount"] = automount if format is not None: - data['format'] = format + 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']), - next_actions=[BoundAction(self._client.actions, action) for action in response['next_actions']] + volume=BoundVolume(self, response["volume"]), + action=BoundAction(self._parent.actions, response["action"]), + next_actions=[ + 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 `) + def get_actions_list( + 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) - actions = [BoundAction(self._client.actions, action_data) for action_data in response['actions']] - return add_meta_to_result(actions, response, 'actions') + :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, + ) - 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 - """ Updates the volume properties. + 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 ` :param name: str (optional) @@ -287,26 +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), method="PUT", json=data) - return BoundVolume(self, response['volume']) + response = self._client.request( + 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 ` @@ -314,12 +390,19 @@ def resize(self, volume, size): New volume size in GB (must be greater than current size) :return: :class:`BoundAction ` """ - data = self._client.request(url="/volumes/{volume_id}/actions/resize".format(volume_id=volume.id), - json={'size': size}, method="POST") - return BoundAction(self._client.actions, data['action']) - - def attach(self, volume, server, automount=None): - # type: (Union[Volume, BoundVolume], Union[Server, BoundServer], Optional[bool]) -> BoundAction + data = self._client.request( + url=f"{self._base_url}/{volume.id}/actions/resize", + json={"size": size}, + method="POST", + ) + return BoundAction(self._parent.actions, data["action"]) + + 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 ` @@ -327,27 +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), json=data, - method="POST") - return BoundAction(self._client.actions, data['action']) + data = self._client.request( + url=f"{self._base_url}/{volume.id}/actions/attach", + json=data, + method="POST", + ) + 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), - method="POST") - return BoundAction(self._client.actions, data['action']) + data = self._client.request( + url=f"{self._base_url}/{volume.id}/actions/detach", + method="POST", + ) + 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 ` @@ -355,11 +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), - method="POST", json=data) - return BoundAction(self._client.actions, response['action']) + url=f"{self._base_url}/{volume.id}/actions/change_protection", + method="POST", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/volumes/domain.py b/hcloud/volumes/domain.py index 20d932d8..2ed33338 100644 --- a/hcloud/volumes/domain.py +++ b/hcloud/volumes/domain.py @@ -1,6 +1,20 @@ -# -*- coding: utf-8 -*- -from hcloud.core.domain import BaseDomain, DomainIdentityMixin -from hcloud.helpers.descriptors import ISODateTime +from __future__ import annotations + +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): @@ -29,14 +43,13 @@ class Volume(BaseDomain, DomainIdentityMixin): :param format: str, None Filesystem of the volume if formatted on creation, None if not formatted on creation. """ + STATUS_CREATING = "creating" """Volume Status creating""" STATUS_AVAILABLE = "available" """Volume Status available""" - created = ISODateTime() - - __slots__ = ( + __api_properties__ = ( "id", "name", "server", @@ -46,30 +59,29 @@ class Volume(BaseDomain, DomainIdentityMixin): "format", "protection", "labels", - "status" + "status", + "created", ) - - supported_fields = ("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 - + self, + 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 = created + self.created = self._parse_datetime(created) self.location = location self.size = size self.linux_device = linux_device @@ -79,6 +91,10 @@ def __init__( self.status = status +class VolumeProtection(TypedDict): + delete: bool + + class CreateVolumeResponse(BaseDomain): """Create Volume Response Domain @@ -89,17 +105,15 @@ class CreateVolumeResponse(BaseDomain): :param next_actions: List[:class:`BoundAction `] 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] + self, + 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 e15f3625..00000000 --- a/requirements/test.txt +++ /dev/null @@ -1,7 +0,0 @@ --r base.txt - -flake8==3.6.0 -isort==4.3.4 -mock==2.0.0 -pytest==3.10.0 -tox==3.5.3 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 0a869337..93f2a6ab 100644 --- a/setup.py +++ b/setup.py @@ -1,60 +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", - "python-dateutil==2.7.5", - "requests==2.20.0" -] - -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.23.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', + 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 :: 2", - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <3.8', - 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.2", + "watchdog>=6,<6.1", + ], + "test": [ + "coverage>=7.15,<7.16", + "pylint>=4,<4.1", + "pytest>=9,<9.2", + "pytest-cov>=7,<7.2", + "mypy>=2.3,<2.4", + "types-python-dateutil", + "types-requests", + ], + }, 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/integration/actions/test_actions.py b/tests/integration/actions/test_actions.py deleted file mode 100644 index f882a98d..00000000 --- a/tests/integration/actions/test_actions.py +++ /dev/null @@ -1,14 +0,0 @@ - -class TestActionsClient(object): - def test_get_by_id(self, hetzner_client): - action = hetzner_client.actions.get_by_id(13) - assert action.id == 13 - assert action.command == "start_server" - assert action.progress == 100 - - def test_get_list(self, hetzner_client): - result = hetzner_client.actions.get_list() - actions = result.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" - assert actions[0].progress == 100 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py deleted file mode 100644 index 1d70f51c..00000000 --- a/tests/integration/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -import os -import pytest - -from hcloud import Client - - -@pytest.fixture(autouse=True, scope='function') -def hetzner_client(): - hetzner_client = Client(token="test-token", api_endpoint=os.getenv("FAKE_API_ENDPOINT", default="http://localhost:4000/v1")) - return hetzner_client diff --git a/tests/integration/datacenters/test_datacenters.py b/tests/integration/datacenters/test_datacenters.py deleted file mode 100644 index ffb3c585..00000000 --- a/tests/integration/datacenters/test_datacenters.py +++ /dev/null @@ -1,19 +0,0 @@ -class TestDatacentersClient(object): - def test_get_by_id(self, hetzner_client): - datacenter = hetzner_client.datacenters.get_by_id(1) - assert datacenter.id == 1 - assert datacenter.name == "fsn1-dc8" - assert datacenter.description == "Falkenstein 1 DC 8" - - def test_get_by_name(self, hetzner_client): - datacenter = hetzner_client.datacenters.get_by_name("fsn1-dc8") - assert datacenter.id == 1 - assert datacenter.name == "fsn1-dc8" - assert datacenter.description == "Falkenstein 1 DC 8" - - def test_get_list(self, hetzner_client): - result = hetzner_client.datacenters.get_list() - datacenters = result.datacenters - assert datacenters[0].id == 1 - assert datacenters[0].name == "fsn1-dc8" - assert datacenters[0].description == "Falkenstein 1 DC 8" diff --git a/tests/integration/floating_ips/test_floating_ips.py b/tests/integration/floating_ips/test_floating_ips.py deleted file mode 100644 index ff258c18..00000000 --- a/tests/integration/floating_ips/test_floating_ips.py +++ /dev/null @@ -1,129 +0,0 @@ -import pytest -import mock - -from hcloud.servers.client import BoundServer -from hcloud.servers.domain import Server -from hcloud.floating_ips.client import BoundFloatingIP -from hcloud.floating_ips.domain import FloatingIP - - -class TestBoundFloatingIPs(object): - - @pytest.fixture() - def bound_floating_ip(self, hetzner_client): - return BoundFloatingIP(client=hetzner_client.floating_ips, data=dict(id=4711)) - - def test_get_actions(self, bound_floating_ip): - actions = bound_floating_ip.get_actions() - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "assign_floating_ip" - - def test_update(self, bound_floating_ip): - floating_ip = bound_floating_ip.update(description="New description", labels={}) - assert floating_ip.id == 4711 - assert floating_ip.description == "New description" - - def test_delete(self, bound_floating_ip): - delete_success = bound_floating_ip.delete() - assert delete_success is True - - @pytest.mark.parametrize("server", - (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1)))) - def test_assign(self, hetzner_client, bound_floating_ip, server): - action = bound_floating_ip.assign(server) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "assign_floating_ip" - - def test_unassign(self, hetzner_client, bound_floating_ip): - action = bound_floating_ip.unassign() - assert action.id == 13 - assert action.progress == 0 - assert action.command == "unassign_floating_ip" - - def test_change_dns_ptr(self, hetzner_client, bound_floating_ip): - action = bound_floating_ip.change_dns_ptr("1.2.3.4", "server02.example.com") - assert action.id == 13 - assert action.progress == 0 - assert action.command == "change_dns_ptr" - - -class TestFloatingIPsClient(object): - - def test_get_by_id(self, hetzner_client): - bound_floating_ip = hetzner_client.floating_ips.get_by_id(4711) - assert bound_floating_ip.id == 4711 - assert bound_floating_ip.description == "Web Frontend" - assert bound_floating_ip.type == "ipv4" - - def test_get_list(self, hetzner_client): - result = hetzner_client.floating_ips.get_list() - bound_floating_ips = result.floating_ips - assert bound_floating_ips[0].id == 4711 - assert bound_floating_ips[0].description == "Web Frontend" - assert bound_floating_ips[0].type == "ipv4" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_create(self, hetzner_client, server): - response = hetzner_client.floating_ips.create( - type="ipv4", - description="Web Frontend", - # home_location=Location(description="fsn1"), - server=server - ) - - floating_ip = response.floating_ip - action = response.action - - assert floating_ip.id == 4711 - assert floating_ip.description == "Web Frontend" - assert floating_ip.type == "ipv4" - - assert action.id == 13 - assert action.command == "assign_floating_ip" - - @pytest.mark.parametrize("floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))]) - def test_get_actions(self, hetzner_client, floating_ip): - actions = hetzner_client.floating_ips.get_actions(floating_ip) - - assert len(actions) == 1 - 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, hetzner_client, floating_ip): - floating_ip = hetzner_client.floating_ips.update(floating_ip, description="New description", labels={}) - - assert floating_ip.id == 4711 - assert floating_ip.description == "New description" - - @pytest.mark.parametrize("floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))]) - def test_delete(self, hetzner_client, floating_ip): - delete_success = hetzner_client.floating_ips.delete(floating_ip) - - assert delete_success is True - - @pytest.mark.parametrize("server,floating_ip", - [(Server(id=43), FloatingIP(id=4711)), - (BoundServer(mock.MagicMock(), dict(id=43)), BoundFloatingIP(mock.MagicMock(), dict(id=4711)))]) - def test_assign(self, hetzner_client, server, floating_ip): - action = hetzner_client.floating_ips.assign(floating_ip, server) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "assign_floating_ip" - - @pytest.mark.parametrize("floating_ip", [FloatingIP(id=4711), BoundFloatingIP(mock.MagicMock(), dict(id=4711))]) - def test_unassign(self, hetzner_client, floating_ip): - action = hetzner_client.floating_ips.unassign(floating_ip) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "unassign_floating_ip" - - @pytest.mark.parametrize("floating_ip", [FloatingIP(id=4711), BoundFloatingIP(mock.MagicMock(), dict(id=4711))]) - def test_change_dns_ptr(self, hetzner_client, floating_ip): - action = hetzner_client.floating_ips.change_dns_ptr(floating_ip, "1.2.3.4", "server02.example.com") - assert action.id == 13 - assert action.progress == 0 - assert action.command == "change_dns_ptr" diff --git a/tests/integration/images/test_images.py b/tests/integration/images/test_images.py deleted file mode 100644 index eb9a416b..00000000 --- a/tests/integration/images/test_images.py +++ /dev/null @@ -1,86 +0,0 @@ -import mock -import pytest - -from hcloud.images.client import BoundImage -from hcloud.images.domain import Image - - -class TestBoundImage(object): - @pytest.fixture() - def bound_image(self, hetzner_client): - return BoundImage(client=hetzner_client.images, data=dict(id=42)) - - def test_get_actions_list(self, bound_image): - result = bound_image.get_actions_list() - actions = result.actions - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - def test_update(self, bound_image): - image = bound_image.update(description="My new Image description", type="snapshot", labels={}) - - assert image.id == 4711 - assert image.description == "My new Image description" - - def test_delete(self, bound_image): - delete_success = bound_image.delete() - - assert delete_success is True - - def test_change_protection(self, bound_image): - action = bound_image.change_protection(True) - - assert action.id == 13 - assert action.command == "change_protection" - - -class TestImagesClient(object): - def test_get_by_id(self, hetzner_client): - image = hetzner_client.images.get_by_id(1) - assert image.id == 4711 - assert image.name == "ubuntu-16.04" - assert image.description == "Ubuntu 16.04 Standard 64 bit" - - def test_get_by_name(self, hetzner_client): - image = hetzner_client.images.get_by_name("ubuntu-16.04") - assert image.id == 4711 - assert image.name == "ubuntu-16.04" - assert image.description == "Ubuntu 16.04 Standard 64 bit" - - def test_get_list(self, hetzner_client): - result = hetzner_client.images.get_list() - images = result.images - assert images[0].id == 4711 - assert images[0].name == "ubuntu-16.04" - assert images[0].description == "Ubuntu 16.04 Standard 64 bit" - - @pytest.mark.parametrize("image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))]) - def test_get_actions_list(self, hetzner_client, image): - result = hetzner_client.images.get_actions_list(image) - actions = result.actions - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "change_protection" - - @pytest.mark.parametrize("image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))]) - def test_update(self, hetzner_client, image): - image = hetzner_client.images.update(image, description="My new Image description", type="snapshot", labels={}) - - assert image.id == 4711 - assert image.description == "My new Image description" - - @pytest.mark.parametrize("image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))]) - def test_delete(self, hetzner_client, image): - delete_success = hetzner_client.images.delete(image) - - assert delete_success is True - - @pytest.mark.parametrize("image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))]) - def test_change_protection(self, hetzner_client, image): - action = hetzner_client.images.change_protection(image, delete=True) - - assert action.id == 13 - assert action.command == "change_protection" diff --git a/tests/integration/isos/test_isos.py b/tests/integration/isos/test_isos.py deleted file mode 100644 index 16f2372b..00000000 --- a/tests/integration/isos/test_isos.py +++ /dev/null @@ -1,22 +0,0 @@ -class TestIsosClient(object): - def test_get_by_id(self, hetzner_client): - iso = hetzner_client.isos.get_by_id(1) - assert iso.id == 4711 - assert iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - assert iso.description == "FreeBSD 11.0 x64" - assert iso.type == "public" - - def test_get_by_name(self, hetzner_client): - iso = hetzner_client.isos.get_by_name("FreeBSD-11.0-RELEASE-amd64-dvd1") - assert iso.id == 4711 - assert iso.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - assert iso.description == "FreeBSD 11.0 x64" - assert iso.type == "public" - - def test_get_list(self, hetzner_client): - result = hetzner_client.isos.get_list() - isos = result.isos - assert isos[0].id == 4711 - assert isos[0].name == "FreeBSD-11.0-RELEASE-amd64-dvd1" - assert isos[0].description == "FreeBSD 11.0 x64" - assert isos[0].type == "public" diff --git a/tests/integration/locations/test_locations.py b/tests/integration/locations/test_locations.py deleted file mode 100644 index 6e6aa3b1..00000000 --- a/tests/integration/locations/test_locations.py +++ /dev/null @@ -1,19 +0,0 @@ -class TestLocationsClient(object): - def test_get_by_id(self, hetzner_client): - location = hetzner_client.locations.get_by_id(1) - assert location.id == 1 - assert location.name == "fsn1" - assert location.description == "Falkenstein DC Park 1" - - def test_get_by_name(self, hetzner_client): - location = hetzner_client.locations.get_by_name("fsn1") - assert location.id == 1 - assert location.name == "fsn1" - assert location.description == "Falkenstein DC Park 1" - - def test_get_list(self, hetzner_client): - result = hetzner_client.locations.get_list() - locations = result.locations - assert locations[0].id == 1 - assert locations[0].name == "fsn1" - assert locations[0].description == "Falkenstein DC Park 1" diff --git a/tests/integration/server_types/test_server_types.py b/tests/integration/server_types/test_server_types.py deleted file mode 100644 index 7810f29b..00000000 --- a/tests/integration/server_types/test_server_types.py +++ /dev/null @@ -1,75 +0,0 @@ - -class TestServerTypesClient(object): - - def test_get_by_id(self, hetzner_client): - server_type = hetzner_client.server_types.get_by_id(1) - assert server_type.id == 1 - assert server_type.name == "cx11" - assert server_type.description == "CX11" - assert server_type.cores == 1 - assert server_type.memory == 1 - assert server_type.disk == 25 - assert server_type.prices == [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - } - } - ] - assert server_type.storage_type == "local" - assert server_type.cpu_type == "shared" - - def test_get_by_name(self, hetzner_client): - server_type = hetzner_client.server_types.get_by_name("cx11") - assert server_type.id == 1 - assert server_type.name == "cx11" - assert server_type.description == "CX11" - assert server_type.cores == 1 - assert server_type.memory == 1 - assert server_type.disk == 25 - assert server_type.prices == [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - } - } - ] - assert server_type.storage_type == "local" - assert server_type.cpu_type == "shared" - - def test_get_list(self, hetzner_client): - result = hetzner_client.server_types.get_list() - server_types = result.server_types - assert server_types[0].id == 1 - assert server_types[0].name == "cx11" - assert server_types[0].description == "CX11" - assert server_types[0].cores == 1 - assert server_types[0].memory == 1 - assert server_types[0].disk == 25 - assert server_types[0].prices == [ - { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - } - } - ] - assert server_types[0].storage_type == "local" - assert server_types[0].cpu_type == "shared" diff --git a/tests/integration/servers/test_servers.py b/tests/integration/servers/test_servers.py deleted file mode 100644 index 1a82abd5..00000000 --- a/tests/integration/servers/test_servers.py +++ /dev/null @@ -1,357 +0,0 @@ -import mock -import pytest - -from hcloud.servers.client import BoundServer -from hcloud.servers.domain import Server -from hcloud.ssh_keys.domain import SSHKey -from hcloud.volumes.domain import Volume -from hcloud.images.domain import Image -from hcloud.isos.domain import Iso -from hcloud.server_types.domain import ServerType -from hcloud.locations.domain import Location - - -class TestBoundServer(object): - - @pytest.fixture() - def bound_server(self, hetzner_client): - return BoundServer(client=hetzner_client.servers, data=dict(id=42)) - - def test_get_actions_list(self, bound_server): - result = bound_server.get_actions_list() - actions = result.actions - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - def test_update(self, bound_server): - server = bound_server.update(name="new-name", labels={}) - assert server.id == 42 - assert server.name == "new-name" - - def test_delete(self, bound_server): - action = bound_server.delete() - assert action.id == 13 - assert action.command == "delete_server" - - def test_power_off(self, bound_server): - action = bound_server.power_off() - assert action.id == 13 - assert action.command == "stop_server" - - def test_power_on(self, bound_server): - action = bound_server.power_on() - - assert action.id == 13 - assert action.command == "start_server" - - def test_reboot(self, bound_server): - action = bound_server.reboot() - - assert action.id == 13 - assert action.command == "reboot_server" - - def test_reset(self, bound_server): - action = bound_server.reset() - - assert action.id == 13 - assert action.command == "reset_server" - - def test_reset_password(self, bound_server): - response = bound_server.reset_password() - - assert response.action.id == 13 - assert response.action.command == "reset_password" - assert response.root_password == "zCWbFhnu950dUTko5f40" - - def test_change_type(self, bound_server): - action = bound_server.change_type(ServerType(name="cx11"), upgrade_disk=True) - assert action.id == 13 - assert action.command == "change_server_type" - - def test_enable_rescue(self, bound_server): - response = bound_server.enable_rescue(type="linux64", ssh_keys=[2323]) - - assert response.action.id == 13 - assert response.action.command == "enable_rescue" - assert response.root_password == "zCWbFhnu950dUTko5f40" - - def test_disable_rescue(self, bound_server): - action = bound_server.disable_rescue() - - assert action.id == 13 - assert action.command == "disable_rescue" - - def test_create_image(self, bound_server): - response = bound_server.create_image(description="my image", type="snapshot") - - assert response.action.id == 13 - assert response.action.command == "create_image" - assert response.image.description == "my image" - - def test_rebuild(self, bound_server): - action = bound_server.rebuild(Image(name="ubuntu-16.04")) - - assert action.id == 13 - assert action.command == "rebuild_server" - - def test_enable_backup(self, bound_server): - action = bound_server.enable_backup() - - assert action.id == 13 - assert action.command == "enable_backup" - - def test_disable_backup(self, bound_server): - action = bound_server.disable_backup() - - assert action.id == 13 - assert action.command == "disable_backup" - - def test_attach_iso(self, bound_server): - action = bound_server.attach_iso(Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) - - assert action.id == 13 - assert action.command == "attach_iso" - - def test_detach_iso(self, bound_server): - action = bound_server.detach_iso() - - assert action.id == 13 - assert action.command == "detach_iso" - - def test_change_dns_ptr(self, bound_server): - action = bound_server.change_dns_ptr(ip="1.2.3.4", dns_ptr="example.com") - - assert action.id == 13 - assert action.command == "change_dns_ptr" - - def test_change_protection(self, bound_server): - action = bound_server.change_protection(True, True) - - assert action.id == 13 - assert action.command == "change_protection" - - def test_request_console(self, bound_server): - response = bound_server.request_console() - - assert response.action.id == 13 - assert response.action.command == "request_console" - assert response.wss_url == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" - assert response.password == "9MQaTg2VAGI0FIpc10k3UpRXcHj2wQ6x" - - -class TestServersClient(object): - - def test_get_by_id(self, hetzner_client): - server = hetzner_client.servers.get_by_id(42) - assert server.id == 42 - assert server.volumes == [] - assert server.server_type.id == 1 - assert server.datacenter.id == 1 - assert server.image.id == 4711 - - def test_get_by_name(self, hetzner_client): - server = hetzner_client.servers.get_by_name("my-server") - assert server.id == 42 - assert server.name == "my-server" - assert server.volumes == [] - assert server.server_type.id == 1 - assert server.datacenter.id == 1 - assert server.image.id == 4711 - - def test_get_list(self, hetzner_client): - result = hetzner_client.servers.get_list(42) - servers = result.servers - assert servers[0].id == 42 - assert servers[0].volumes == [] - assert servers[0].server_type.id == 1 - assert servers[0].datacenter.id == 1 - assert servers[0].image.id == 4711 - - def test_create(self, hetzner_client): - - response = hetzner_client.servers.create( - "my-server", - server_type=ServerType(name="cx11"), - image=Image(name="ubuntu-16.04"), - ssh_keys=[SSHKey(name="my-ssh-key")], - volumes=[Volume(id=1)], - user_data="#cloud-config\\nruncmd:\\n- [touch, /root/cloud-init-worked]\\n", - location=Location(name="nbg1"), - automount=False - - ) - server = response.server - action = response.action - next_actions = response.next_actions - root_password = response.root_password - - assert server.id == 42 - assert server.volumes == [] - assert server.server_type.id == 1 - assert server.datacenter.id == 1 - assert server.image.id == 4711 - - assert action.id == 1 - assert action.command == "create_server" - - assert len(next_actions) == 1 - assert next_actions[0].id == 13 - assert next_actions[0].command == "start_server" - - assert root_password == "YItygq1v3GYjjMomLaKc" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_get_actions_list(self, hetzner_client, server): - result = hetzner_client.servers.get_actions_list(server) - actions = result.actions - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "start_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_update(self, hetzner_client, server): - server = hetzner_client.servers.update(server, name="new-name", labels={}) - - assert server.id == 42 - assert server.name == "new-name" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_delete(self, hetzner_client, server): - action = hetzner_client.servers.delete(server) - - assert action.id == 13 - assert action.command == "delete_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_power_off(self, hetzner_client, server): - action = hetzner_client.servers.power_off(server) - - assert action.id == 13 - assert action.command == "stop_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_power_on(self, hetzner_client, server): - action = hetzner_client.servers.power_on(server) - - assert action.id == 13 - assert action.command == "start_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_reboot(self, hetzner_client, server): - action = hetzner_client.servers.reboot(server) - - assert action.id == 13 - assert action.command == "reboot_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_reset(self, hetzner_client, server): - action = hetzner_client.servers.reset(server) - - assert action.id == 13 - assert action.command == "reset_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_shutdown(self, hetzner_client, server): - action = hetzner_client.servers.shutdown(server) - - assert action.id == 13 - assert action.command == "shutdown_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_reset_password(self, hetzner_client, server): - response = hetzner_client.servers.reset_password(server) - - assert response.action.id == 13 - assert response.action.command == "reset_password" - assert response.root_password == "zCWbFhnu950dUTko5f40" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_change_type(self, hetzner_client, server): - action = hetzner_client.servers.change_type(server, ServerType(name="cx11"), upgrade_disk=True) - - assert action.id == 13 - assert action.command == "change_server_type" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_enable_rescue(self, hetzner_client, server): - response = hetzner_client.servers.enable_rescue(server, type="linux64", ssh_keys=[2323]) - - assert response.action.id == 13 - assert response.action.command == "enable_rescue" - assert response.root_password == "zCWbFhnu950dUTko5f40" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_disable_rescue(self, hetzner_client, server): - action = hetzner_client.servers.disable_rescue(server) - - assert action.id == 13 - assert action.command == "disable_rescue" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_create_image(self, hetzner_client, server): - response = hetzner_client.servers.create_image(server, description="my image", type="snapshot") - - assert response.action.id == 13 - assert response.action.command == "create_image" - assert response.image.description == "my image" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_rebuild(self, hetzner_client, server): - action = hetzner_client.servers.rebuild(server, Image(name="ubuntu-16.04")) - - assert action.id == 13 - assert action.command == "rebuild_server" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_enable_backup(self, hetzner_client, server): - action = hetzner_client.servers.enable_backup(server) - - assert action.id == 13 - assert action.command == "enable_backup" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_disable_backup(self, hetzner_client, server): - action = hetzner_client.servers.disable_backup(server) - - assert action.id == 13 - assert action.command == "disable_backup" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_attach_iso(self, hetzner_client, server): - action = hetzner_client.servers.attach_iso(server, Iso(name="FreeBSD-11.0-RELEASE-amd64-dvd1")) - - assert action.id == 13 - assert action.command == "attach_iso" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_detach_iso(self, hetzner_client, server): - action = hetzner_client.servers.detach_iso(server) - - assert action.id == 13 - assert action.command == "detach_iso" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_change_dns_ptr(self, hetzner_client, server): - action = hetzner_client.servers.change_dns_ptr(server, "1.2.3.4", "example.com") - - assert action.id == 13 - assert action.command == "change_dns_ptr" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_change_protection(self, hetzner_client, server): - action = hetzner_client.servers.change_protection(server, delete=True, rebuild=True) - - assert action.id == 13 - assert action.command == "change_protection" - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_request_console(self, hetzner_client, server): - response = hetzner_client.servers.request_console(server) - - assert response.action.id == 13 - assert response.action.command == "request_console" - assert response.wss_url == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" - assert response.password == "9MQaTg2VAGI0FIpc10k3UpRXcHj2wQ6x" diff --git a/tests/integration/ssh_keys/test_ssh_keys.py b/tests/integration/ssh_keys/test_ssh_keys.py deleted file mode 100644 index ec7d01a8..00000000 --- a/tests/integration/ssh_keys/test_ssh_keys.py +++ /dev/null @@ -1,64 +0,0 @@ -import mock -import pytest - -from hcloud.ssh_keys.client import BoundSSHKey -from hcloud.ssh_keys.domain import SSHKey - - -class TestBoundSSHKey(object): - @pytest.fixture() - def bound_ssh_key(self, hetzner_client): - return BoundSSHKey(client=hetzner_client.ssh_keys, data=dict(id=42)) - - def test_update(self, bound_ssh_key): - ssh_key = bound_ssh_key.update(name="New name") - - assert ssh_key.id == 2323 - assert ssh_key.name == "New name" - - def test_delete(self, bound_ssh_key): - delete_success = bound_ssh_key.delete() - - assert delete_success is True - - -class TestSSHKeysClient(object): - def test_get_by_id(self, hetzner_client): - ssh_key = hetzner_client.ssh_keys.get_by_id(1) - assert ssh_key.id == 2323 - assert ssh_key.name == "My ssh key" - assert ssh_key.fingerprint == "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" - assert ssh_key.public_key == "ssh-rsa AAAjjk76kgf...Xt" - - def test_get_by_name(self, hetzner_client): - ssh_key = hetzner_client.ssh_keys.get_by_name("My ssh key") - assert ssh_key.id == 2323 - assert ssh_key.name == "My ssh key" - assert ssh_key.fingerprint == "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" - assert ssh_key.public_key == "ssh-rsa AAAjjk76kgf...Xt" - - def test_get_list(self, hetzner_client): - ssh_keys = hetzner_client.ssh_keys.get_all() - assert ssh_keys[0].id == 2323 - assert ssh_keys[0].name == "My ssh key" - assert ssh_keys[0].fingerprint == "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" - assert ssh_keys[0].public_key == "ssh-rsa AAAjjk76kgf...Xt" - - def test_create(self, hetzner_client): - ssh_key = hetzner_client.ssh_keys.create(name="My ssh key", public_key="ssh-rsa AAAjjk76kgf...Xt") - - assert ssh_key.id == 2323 - assert ssh_key.name == "My ssh key" - - @pytest.mark.parametrize("ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))]) - def test_update(self, hetzner_client, ssh_key): - ssh_key = hetzner_client.ssh_keys.update(ssh_key, name="New name") - - assert ssh_key.id == 2323 - assert ssh_key.name == "New name" - - @pytest.mark.parametrize("ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))]) - def test_delete(self, hetzner_client, ssh_key): - delete_success = hetzner_client.ssh_keys.delete(ssh_key) - - assert delete_success is True diff --git a/tests/integration/volumes/test_volumes.py b/tests/integration/volumes/test_volumes.py deleted file mode 100644 index ae3a5fb7..00000000 --- a/tests/integration/volumes/test_volumes.py +++ /dev/null @@ -1,142 +0,0 @@ -import pytest -import mock - -from hcloud.servers.client import BoundServer -from hcloud.servers.domain import Server -from hcloud.volumes.client import BoundVolume -from hcloud.volumes.domain import Volume -from hcloud.locations.domain import Location - - -class TestBoundVolume(object): - - @pytest.fixture() - def bound_volume(self, hetzner_client): - return BoundVolume(client=hetzner_client.volumes, data=dict(id=4711)) - - def test_get_actions(self, bound_volume): - actions = bound_volume.get_actions() - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - - def test_update(self, bound_volume): - volume = bound_volume.update(name="new-name", labels={}) - assert volume.id == 4711 - assert volume.name == "new-name" - - def test_delete(self, bound_volume): - delete_success = bound_volume.delete() - assert delete_success is True - - @pytest.mark.parametrize("server", - (Server(id=1), BoundServer(mock.MagicMock(), dict(id=1)))) - def test_attach(self, hetzner_client, bound_volume, server): - action = bound_volume.attach(server) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "attach_volume" - - def test_detach(self, hetzner_client, bound_volume): - action = bound_volume.detach() - assert action.id == 13 - assert action.progress == 0 - assert action.command == "detach_volume" - - def test_resize(self, hetzner_client, bound_volume): - action = bound_volume.resize(50) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "resize_volume" - - -class TestVolumesClient(object): - - def test_get_by_id(self, hetzner_client): - bound_volume = hetzner_client.volumes.get_by_id(4711) - assert bound_volume.id == 4711 - assert bound_volume.name == "database-storage" - assert bound_volume.size == 42 - - def test_get_by_name(self, hetzner_client): - bound_volume = hetzner_client.volumes.get_by_name("database-storage") - assert bound_volume.id == 4711 - assert bound_volume.name == "database-storage" - assert bound_volume.size == 42 - - def test_get_list(self, hetzner_client): - result = hetzner_client.volumes.get_list() - bound_volumes = result.volumes - assert bound_volumes[0].id == 4711 - assert bound_volumes[0].name == "database-storage" - assert bound_volumes[0].size == 42 - - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_create(self, hetzner_client, server): - response = hetzner_client.volumes.create( - 42, - "test-database", - location=Location(name="nbg1"), - automount=False, - format="xfs" - ) - - volume = response.volume - action = response.action - next_actions = response.next_actions - - assert volume.id == 4711 - assert volume.name == "database-storage" - assert volume.size == 42 - - assert action.id == 13 - assert action.command == "create_volume" - - assert len(next_actions) == 1 - assert next_actions[0].id == 13 - assert next_actions[0].command == "start_server" - - @pytest.mark.parametrize("volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))]) - def test_get_actions(self, hetzner_client, volume): - actions = hetzner_client.volumes.get_actions(volume) - - assert len(actions) == 1 - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" - - @pytest.mark.parametrize("volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))]) - def test_update(self, hetzner_client, volume): - volume = hetzner_client.volumes.update(volume, name="new-name", labels={}) - - assert volume.id == 4711 - assert volume.name == "new-name" - - @pytest.mark.parametrize("volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))]) - def test_delete(self, hetzner_client, volume): - delete_success = hetzner_client.volumes.delete(volume) - - assert delete_success is True - - @pytest.mark.parametrize("server,volume", - [(Server(id=43), Volume(id=4711)), - (BoundServer(mock.MagicMock(), dict(id=43)), BoundVolume(mock.MagicMock(), dict(id=4711)))]) - def test_attach(self, hetzner_client, server, volume): - action = hetzner_client.volumes.attach(volume, server) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "attach_volume" - - @pytest.mark.parametrize("volume", [Volume(id=4711), BoundVolume(mock.MagicMock(), dict(id=4711))]) - def test_detach(self, hetzner_client, volume): - action = hetzner_client.volumes.detach(volume) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "detach_volume" - - @pytest.mark.parametrize("volume", [Volume(id=4711), BoundVolume(mock.MagicMock(), dict(id=4711))]) - def test_resize(self, hetzner_client, volume): - action = hetzner_client.volumes.resize(volume, 50) - assert action.id == 13 - assert action.progress == 0 - assert action.command == "resize_volume" diff --git a/tests/unit/actions/conftest.py b/tests/unit/actions/conftest.py deleted file mode 100644 index 811277c0..00000000 --- a/tests/unit/actions/conftest.py +++ /dev/null @@ -1,117 +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 8cf97cd4..26930de4 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -1,109 +1,479 @@ -import mock +from __future__ import annotations + +import inspect +import warnings +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. + """ + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + + members = inspect.getmembers( + client, + predicate=lambda p: isinstance(p, ResourceClientBase) + and hasattr(p, "actions"), + ) + for name, member in members: + assert name in resources_with_actions -class TestBoundAction(object): + resource_client_class, _ = resources_with_actions[name] + assert member.__class__ is resource_client_class + + assert len(members) == len(resources_with_actions) + + +class TestBoundAction: @pytest.fixture() - def bound_running_action(self, mocked_requests): - 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, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_success, + ): + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_success}, + ] - def test_wait_until_finished(self, bound_running_action, mocked_requests, running_action, successfully_action): - mocked_requests.request.side_effect = [running_action, successfully_action] 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 - def test_wait_until_finished_with_error(self, bound_running_action, mocked_requests, running_action, failed_action): - mocked_requests.request.side_effect = [running_action, failed_action] - with pytest.raises(ActionFailedException) as exception_info: + assert request_mock.call_count == 2 + + def test_wait_until_finished_with_error( + self, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_error, + ): + 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): - mocked_requests.request.side_effect = [running_action, running_action, successfully_action] - with pytest.raises(ActionTimeoutException) as exception_info: + def test_wait_until_finished_max_retries( + self, + request_mock: mock.MagicMock, + bound_running_action, + action1_running, + action1_success, + ): + request_mock.side_effect = [ + {"action": action1_running}, + {"action": action1_running}, + {"action": action1_success}, + ] + + 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 TestActionsClient(object): +class TestResourceActionsClient: + """ + //actions + //actions/ + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request) -> str: + return request.param @pytest.fixture() - def actions_client(self): - return 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") - assert action._client is actions_client - assert action.id == 1 - assert action.command == "stop_server" + 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": ["active"], - "sort": ["status"], - "page": 2, - "per_page": 10} - ] + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) - def test_get_list(self, actions_client, generic_action_list, params): - actions_client._client.request.return_value = generic_action_list - result = actions_client.get_list(**params) - actions_client._client.request.assert_called_with(url="/actions", method="GET", params=params) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: ResourceActionsClient, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = resource_client.get_list(**params) - assert result.meta is None + 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) - action1 = actions[0] - action2 = actions[1] - assert action1._client is actions_client - assert action1.id == 1 - assert action1.command == "start_server" +class TestResourceObjectActionsClient: + """ + ///actions + """ - assert action2._client is actions_client - assert action2.id == 2 - assert action2.command == "stop_server" + @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": ["active"], - "sort": ["status"]} - ] + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], ) - 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) + 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 - params.update({"page": 1, "per_page": 50}) + result = resource_client.get_actions_list(bound_model, **params) - actions_client._client.request.assert_called_with(url="/actions", method="GET", params=params) + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, + ) + assert result.meta is not None + + actions = result.actions assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - action1 = actions[0] - action2 = actions[1] + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + resource_client: ResourceClientBase, + resource: str, + bound_model: BoundModelBase, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = resource_client.get_actions(bound_model, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], resource_client._parent.actions) + assert_bound_action2(actions[1], resource_client._parent.actions) - assert action1._client is actions_client - assert action1.id == 1 - assert action1.command == "start_server" - assert action2._client is actions_client - assert action2.id == 2 - assert action2.command == "stop_server" +class TestBoundModelActions: + """ + ///actions + """ + + @pytest.fixture(params=resources_with_actions.keys()) + def resource(self, request): + return request.param + + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> ResourceClientBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"], "page": 2, "per_page": 10}, + ], + ) + def test_get_actions_list( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + result = bound_model.get_actions_list(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params=params, + ) + + assert result.meta is not None + + actions = result.actions + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) + + @pytest.mark.parametrize( + "params", + [ + {}, + {"status": ["running"], "sort": ["status"]}, + ], + ) + def test_get_actions( + self, + request_mock: mock.MagicMock, + bound_model: BoundModelBase, + resource: str, + action_list_response, + params, + ): + request_mock.return_value = action_list_response + + actions = bound_model.get_actions(**params) + + request_mock.assert_called_with( + method="GET", + url=f"/{resource}/1/actions", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(actions) == 2 + assert_bound_action1(actions[0], bound_model._client._parent.actions) + assert_bound_action2(actions[1], bound_model._client._parent.actions) + + +class TestActionsClient: + @pytest.fixture() + def actions_client(self, client: Client) -> ActionsClient: + return client.actions + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + actions_client: ActionsClient, + action_response, + ): + request_mock.return_value = action_response + + 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) + + @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 + + with pytest.deprecated_call(): + actions = actions_client.get_all(**params) + + 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 4cc11998..395af182 100644 --- a/tests/unit/actions/test_domain.py +++ b/tests/unit/actions/test_domain.py @@ -1,12 +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): - server = Action(id=1, started="2016-01-30T23:50+00:00", finished="2016-03-30T23:50+00:00") - assert server.started == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0)) - assert server.finished == datetime.datetime(2016, 3, 30, 23, 50, tzinfo=tzoffset(None, 0)) + 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=timezone.utc + ) + assert action.finished == datetime.datetime( + 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/integration/__init__.py b/tests/unit/certificates/__init__.py similarity index 100% rename from tests/integration/__init__.py rename to tests/unit/certificates/__init__.py diff --git a/tests/unit/certificates/conftest.py b/tests/unit/certificates/conftest.py new file mode 100644 index 00000000..a13873da --- /dev/null +++ b/tests/unit/certificates/conftest.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def certificate_response(): + return { + "certificate": { + "id": 2323, + "name": "My Certificate", + "type": "managed", + "labels": {}, + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": ["example.com", "webmail.example.com", "www.example.com"], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": { + "issuance": "failed", + "renewal": "scheduled", + "error": {"code": "error_code", "message": "error message"}, + }, + "used_by": [{"id": 42, "type": "server"}], + } + } + + +@pytest.fixture() +def create_managed_certificate_response(): + return { + "certificate": { + "id": 2323, + "name": "My Certificate", + "type": "managed", + "labels": {}, + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": ["example.com", "webmail.example.com", "www.example.com"], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": {"issuance": "pending", "renewal": "scheduled", "error": None}, + "used_by": [{"id": 42, "type": "load_balancer"}], + }, + "action": { + "id": 14, + "command": "issue_certificate", + "status": "success", + "progress": 100, + "started": "2021-01-30T23:55:00+00:00", + "finished": "2021-01-30T23:57:00+00:00", + "resources": [{"id": 896, "type": "certificate"}], + "error": {"code": "action_failed", "message": "Action failed"}, + }, + } + + +@pytest.fixture() +def two_certificates_response(): + return { + "certificates": [ + { + "id": 2323, + "name": "My Certificate", + "labels": {}, + "type": "uploaded", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": [ + "example.com", + "webmail.example.com", + "www.example.com", + ], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [{"id": 42, "type": "load_balancer"}], + }, + { + "id": 2324, + "name": "My website cert", + "labels": {}, + "type": "uploaded", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": [ + "example.com", + "webmail.example.com", + "www.example.com", + ], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [{"id": 42, "type": "load_balancer"}], + }, + ] + } + + +@pytest.fixture() +def one_certificates_response(): + return { + "certificates": [ + { + "id": 2323, + "name": "My Certificate", + "labels": {}, + "type": "uploaded", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": [ + "example.com", + "webmail.example.com", + "www.example.com", + ], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [{"id": 42, "type": "load_balancer"}], + } + ] + } + + +@pytest.fixture() +def response_update_certificate(): + return { + "certificate": { + "id": 2323, + "name": "New name", + "labels": {}, + "type": "uploaded", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "created": "2019-01-08T12:10:00+00:00", + "not_valid_before": "2019-01-08T10:00:00+00:00", + "not_valid_after": "2019-07-08T09:59:59+00:00", + "domain_names": ["example.com", "webmail.example.com", "www.example.com"], + "fingerprint": "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f", + "status": None, + "used_by": [{"id": 42, "type": "load_balancer"}], + } + } + + +@pytest.fixture() +def response_get_actions(): + return { + "actions": [ + { + "id": 13, + "command": "change_protection", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 14, "type": "certificate"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + ] + } + + +@pytest.fixture() +def response_retry_issuance_action(): + return { + "action": { + "id": 14, + "command": "issue_certificate", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50+00:00", + "finished": None, + "resources": [{"id": 42, "type": "certificate"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } diff --git a/tests/unit/certificates/test_client.py b/tests/unit/certificates/test_client.py new file mode 100644 index 00000000..8182bb48 --- /dev/null +++ b/tests/unit/certificates/test_client.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.certificates import ( + BoundCertificate, + Certificate, + CertificatesClient, + ManagedCertificateStatus, +) + +from ..conftest import BoundModelTestCase + + +class TestBoundCertificate(BoundModelTestCase): + methods = [ + BoundCertificate.update, + BoundCertificate.delete, + BoundCertificate.retry_issuance, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.certificates + + @pytest.fixture() + def bound_model(self, resource_client, certificate_response): + return BoundCertificate( + resource_client, data=certificate_response["certificate"] + ) + + def test_init(self, bound_model: BoundCertificate): + o = bound_model + assert o.id == 2323 + assert o.name == "My Certificate" + assert o.type == "managed" + assert o.fingerprint == "03:c7:55:9b:2a:d1:04:17:09:f6:d0:7f:18:34:63:d4:3e:5f" + assert o.certificate == "-----BEGIN CERTIFICATE-----\n..." + assert len(o.domain_names) == 3 + assert o.domain_names[0] == "example.com" + assert o.domain_names[1] == "webmail.example.com" + assert o.domain_names[2] == "www.example.com" + assert isinstance(o.status, ManagedCertificateStatus) + assert o.status.issuance == "failed" + assert o.status.renewal == "scheduled" + assert o.status.error.code == "error_code" + assert o.status.error.message == "error message" + + +class TestCertificatesClient: + @pytest.fixture() + def certificates_client(self, 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 + + certificate = certificates_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/certificates/1", + ) + assert certificate._client is certificates_client + assert certificate.id == 2323 + assert certificate.name == "My Certificate" + + @pytest.mark.parametrize( + "params", + [ + { + "name": "My Certificate", + "label_selector": "k==v", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + 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) + + request_mock.assert_called_with( + method="GET", + url="/certificates", + params=params, + ) + + certificates = result.certificates + assert len(certificates) == 2 + + certificates1 = certificates[0] + certificates2 = certificates[1] + + assert certificates1._client is certificates_client + assert certificates1.id == 2323 + assert certificates1.name == "My Certificate" + + assert certificates2._client is certificates_client + assert certificates2.id == 2324 + assert certificates2.name == "My website cert" + + @pytest.mark.parametrize( + "params", [{"name": "My Certificate", "label_selector": "label1"}, {}] + ) + 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}) + + request_mock.assert_called_with( + method="GET", + url="/certificates", + params=params, + ) + + assert len(certificates) == 2 + + certificates1 = certificates[0] + certificates2 = certificates[1] + + assert certificates1._client is certificates_client + assert certificates1.id == 2323 + assert certificates1.name == "My Certificate" + + assert certificates2._client is certificates_client + assert certificates2.id == 2324 + assert certificates2.name == "My website cert" + + 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"} + + 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, + 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...", + ) + + request_mock.assert_called_with( + method="POST", + url="/certificates", + json={ + "name": "My Certificate", + "certificate": "-----BEGIN CERTIFICATE-----\n...", + "private_key": "-----BEGIN PRIVATE KEY-----\n...", + "type": "uploaded", + }, + ) + + assert certificate.id == 2323 + assert certificate.name == "My Certificate" + + def test_create_managed( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + create_managed_certificate_response, + ): + request_mock.return_value = create_managed_certificate_response + + create_managed_certificate_rsp = certificates_client.create_managed( + name="My Certificate", domain_names=["example.com", "*.example.org"] + ) + + request_mock.assert_called_with( + method="POST", + url="/certificates", + json={ + "name": "My Certificate", + "domain_names": ["example.com", "*.example.org"], + "type": "managed", + }, + ) + + assert create_managed_certificate_rsp.certificate.id == 2323 + assert create_managed_certificate_rsp.certificate.name == "My Certificate" + assert create_managed_certificate_rsp.action.id == 14 + assert create_managed_certificate_rsp.action.command == "issue_certificate" + + @pytest.mark.parametrize( + "certificate", + [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], + ) + def test_update( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_update_certificate, + ): + request_mock.return_value = response_update_certificate + + certificate = certificates_client.update(certificate, name="New name") + + request_mock.assert_called_with( + method="PUT", + url="/certificates/1", + json={"name": "New name"}, + ) + + assert certificate.id == 2323 + assert certificate.name == "New name" + + @pytest.mark.parametrize( + "certificate", + [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], + ) + 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) + + request_mock.assert_called_with( + method="DELETE", + url="/certificates/1", + ) + + assert delete_success is True + + @pytest.mark.parametrize( + "certificate", + [Certificate(id=1), BoundCertificate(mock.MagicMock(), dict(id=1))], + ) + def test_retry_issuance( + self, + request_mock: mock.MagicMock, + certificates_client: CertificatesClient, + certificate, + response_retry_issuance_action, + ): + request_mock.return_value = response_retry_issuance_action + + action = certificates_client.retry_issuance(certificate) + + request_mock.assert_called_with( + method="POST", + url="/certificates/1/actions/retry", + ) + + assert action.id == 14 + assert action.command == "issue_certificate" diff --git a/tests/unit/certificates/test_domain.py b/tests/unit/certificates/test_domain.py new file mode 100644 index 00000000..3c3b2517 --- /dev/null +++ b/tests/unit/certificates/test_domain.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import datetime +from datetime import timezone + +import pytest + +from hcloud.certificates import ( + Certificate, + ManagedCertificateError, + ManagedCertificateStatus, +) + + +@pytest.mark.parametrize( + "value", + [ + (Certificate(id=1),), + (ManagedCertificateError()), + (ManagedCertificateStatus()), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestCertificate: + def test_created_is_datetime(self): + certificate = Certificate( + id=1, + created="2016-01-30T23:50+00:00", + not_valid_after="2016-01-30T23:50+00:00", + not_valid_before="2016-01-30T23:50+00:00", + ) + assert certificate.created == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) + assert certificate.not_valid_after == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) + assert certificate.not_valid_before == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 9c3e1f03..65446f4a 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -1,44 +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 05f2df39..6e9561fd 100644 --- a/tests/unit/core/test_client.py +++ b/tests/unit/core/test_client.py @@ -1,23 +1,27 @@ -import mock -import pytest +from __future__ import annotations + +from typing import Any, NamedTuple +from unittest import mock -from hcloud.core.client import BoundModelBase, ClientEntityBase, GetEntityByNameMixin -from hcloud.core.domain import add_meta_to_result, BaseDomain +import pytest +from hcloud.actions import ActionsPageResult +from hcloud.core import BaseDomain, BoundModelBase, Meta, ResourceClientBase -class TestBoundModelBase(): +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 @@ -28,52 +32,89 @@ def client(self): return client def test_get_exists_model_attribute_complete_model(self, bound_model_class, client): - bound_model = bound_model_class(client=client, data={"id": 1, "name": "name", "description": "my_description"}) + bound_model = bound_model_class( + client=client, + data={"id": 1, "name": "name", "description": "my_description"}, + ) description = bound_model.description client.get_by_id.assert_not_called() assert description == "my_description" - def test_get_non_exists_model_attribute_complete_model(self, bound_model_class, client): - bound_model = bound_model_class(client=client, data={"id": 1, "name": "name", "description": "description"}) + def test_get_non_exists_model_attribute_complete_model( + self, bound_model_class, client + ): + bound_model = bound_model_class( + 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(self, bound_model_class, client): + def test_get_exists_model_attribute_incomplete_model( + self, bound_model_class, client + ): bound_model = bound_model_class(client=client, data={"id": 101}, complete=False) - client.get_by_id.return_value = bound_model_class(client=client, data={"id": 101, "name": "name", "description": "super_description"}) + client.get_by_id.return_value = bound_model_class( + client=client, + data={"id": 101, "name": "name", "description": "super_description"}, + ) description = bound_model.description client.get_by_id.assert_called_once_with(101) assert description == "super_description" assert bound_model.complete is True - def test_get_filled_model_attribute_incomplete_model(self, bound_model_class, client): + def test_get_filled_model_attribute_incomplete_model( + self, bound_model_class, client + ): bound_model = bound_model_class(client=client, data={"id": 101}, complete=False) id = bound_model.id client.get_by_id.assert_not_called() assert id == 101 assert bound_model.complete is False - def test_get_non_exists_model_attribute_incomplete_model(self, bound_model_class, client): + def test_get_non_exists_model_attribute_incomplete_model( + self, bound_model_class, client + ): 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) + results = [ + (r, page, status, per_page) for r in json_content["candies"] + ] + return CandiesPageResult(results, Meta.parse_meta(json_content)) + return CandiesClient(mock.MagicMock()) return constructor @@ -81,50 +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') + results = [ + (r, page, status, per_page) for r in 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 - } - } + "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], @@ -132,31 +169,23 @@ def json_content_function(p): "pagination": { "page": p, "per_page": 11, - "next_page": p + 1 if p < 3 else None + "next_page": p + 1 if p < 3 else None, } - } + }, } 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), (21, 1, "sweet", 50), - (12, 2, "sweet", 50), (22, 2, "sweet", 50), - (13, 3, "sweet", 50), (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' + assert result == [ + (11, 1, "sweet", 50), + (21, 1, "sweet", 50), + (12, 2, "sweet", 50), + (22, 2, "sweet", 50), + (13, 3, "sweet", 50), + (23, 3, "sweet", 50), + ] def test_get_actions_ok(self, client_class_with_actions_constructor): def json_content_function(p): @@ -166,76 +195,46 @@ def json_content_function(p): "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) - - result = candies_client.get_actions(status="sweet") - - assert result == [(11, 1, "sweet", 50), (21, 1, "sweet", 50), - (12, 2, "sweet", 50), (22, 2, "sweet", 50), - (13, 3, "sweet", 50), (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 + "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" + result = candies_client._iter_pages( + candies_client.get_actions_list, status="sweet" + ) + assert result == [ + (11, 1, "sweet", 50), + (21, 1, "sweet", 50), + (12, 2, "sweet", 50), + (22, 2, "sweet", 50), + (13, 3, "sweet", 50), + (23, 3, "sweet", 50), + ] -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 ec6e1373..280dc3cb 100644 --- a/tests/unit/core/test_domain.py +++ b/tests/unit/core/test_domain.py @@ -1,21 +1,19 @@ +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.helpers.descriptors import ISODateTime - +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": {} - } + json_content = {"meta": {}} result = Meta.parse_meta(json_content) assert isinstance(result, Meta) assert result.pagination is None @@ -29,7 +27,7 @@ def test_parse_meta_json_ok(self): "previous_page": 1, "next_page": 3, "last_page": 10, - "total_entries": 100 + "total_entries": 100, } } } @@ -42,45 +40,25 @@ 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", [ (SomeDomain(id=1, name="name"), 1), (SomeDomain(id=1), 1), (SomeDomain(name="name"), "name"), - ]) + ], + ) def test_id_or_name_ok(self, domain, expected_result): assert domain.id_or_name == expected_result @@ -88,65 +66,134 @@ 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 - - -class ActionDomainWithDescriptor(BaseDomain): - started = ISODateTime() - - __slots__ = ("id", "name") - - supported_fields = ("started",) - - def __init__(self, id=None, name="name1", started=None): - self.id = id - self.name = name - self.started = started - + self.started = self._parse_datetime(started) -class ActionDomainWithSupportedFieldsOverride(BaseDomain): - @classmethod - def get_supported_fields(cls): - return set(["id", "started", "name"]) +class SomeOtherDomain(BaseDomain): + __api_properties__ = ("id", "name", "child") + __slots__ = __api_properties__ - def __init__(self, id=None, name="name1", started=None): + def __init__(self, id=None, name=None, child=None): self.id = id self.name = name - self.started = isoparse(started) if started else None + self.child = child -class TestBaseDomain(object): - - @pytest.mark.parametrize("domain", [ActionDomain, ActionDomainWithDescriptor, ActionDomainWithSupportedFieldsOverride]) +class TestBaseDomain: @pytest.mark.parametrize( "data_dict,expected_result", [ - ({"id": 1}, - {"id": 1, "name": "name1", "started": None} - ), - ({"id": 2, "name": "name2"}, - {"id": 2, "name": "name2", "started": None} - ), - ({"id": 3, "foo": "boo", "description": "new"}, - {"id": 3, "name": "name1", "started": None} - ), - ({"id": 4, "foo": "boo", "description": "new", "name": "name-name3", "started": "2016-01-30T23:50+00:00"}, - {"id": 4, "name": "name-name3", "started": isoparse("2016-01-30T23:50+00:00")} - ), - ]) - def test_from_dict_ok(self, domain, data_dict, expected_result): - model = domain.from_dict(data_dict) + ({"id": 1}, {"id": 1, "name": "name1", "started": None}), + ({"id": 2, "name": "name2"}, {"id": 2, "name": "name2", "started": None}), + ( + {"id": 3, "foo": "boo", "description": "new"}, + {"id": 3, "name": "name1", "started": None}, + ), + ( + { + "id": 4, + "foo": "boo", + "description": "new", + "name": "name-name3", + "started": "2016-01-30T23:50+00:00", + }, + { + "id": 4, + "name": "name-name3", + "started": isoparse("2016-01-30T23:50+00:00"), + }, + ), + ], + ) + 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 + + +def test_base_domain_subclasses(): + for c in BaseDomain.__subclasses__(): + assert len(c.__api_properties__) > 0 + assert len(c.__slots__) > 0 diff --git a/tests/unit/datacenters/conftest.py b/tests/unit/datacenters/conftest.py index 8fac826f..68d92b6b 100644 --- a/tests/unit/datacenters/conftest.py +++ b/tests/unit/datacenters/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -15,25 +17,13 @@ def datacenter_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "supported": [1, 2, 3], + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + }, } } @@ -53,25 +43,13 @@ def two_datacenters_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "supported": [1, 2, 3], + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + }, }, { "id": 2, @@ -84,28 +62,16 @@ def two_datacenters_response(): "country": "DE", "city": "Nuremberg", "latitude": 49.452102, - "longitude": 11.076665 + "longitude": 11.076665, }, "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "supported": [1, 2, 3], + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + }, }, ], - "recommendation": 1 + "recommendation": 1, } @@ -124,26 +90,14 @@ def one_datacenters_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "supported": [1, 2, 3], + "available": [1, 2, 3], + "available_for_migration": [1, 2, 3], + }, } ], - "recommendation": 1 + "recommendation": 1, } diff --git a/tests/unit/datacenters/test_client.py b/tests/unit/datacenters/test_client.py index 99101d7c..59d0d25b 100644 --- a/tests/unit/datacenters/test_client.py +++ b/tests/unit/datacenters/test_client.py @@ -1,17 +1,21 @@ +from __future__ import annotations + +import warnings +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 +warnings.filterwarnings("ignore", category=DeprecationWarning) -class TestBoundDatacenter(object): +class TestBoundDatacenter: def test_bound_datacenter_init(self, datacenter_response): bound_datacenter = BoundDatacenter( - client=mock.MagicMock(), - data=datacenter_response['datacenter'] + client=mock.MagicMock(), data=datacenter_response["datacenter"] ) assert bound_datacenter.id == 1 @@ -24,54 +28,89 @@ def test_bound_datacenter_init(self, datacenter_response): assert bound_datacenter.location.name == "fsn1" assert bound_datacenter.location.complete is True - assert isinstance(bound_datacenter.server_types, DatacenterServerTypes) - assert len(bound_datacenter.server_types.supported) == 3 - assert bound_datacenter.server_types.supported[0].id == 1 - assert bound_datacenter.server_types.supported[0].complete is False - assert bound_datacenter.server_types.supported[1].id == 2 - assert bound_datacenter.server_types.supported[1].complete is False - assert bound_datacenter.server_types.supported[2].id == 3 - assert bound_datacenter.server_types.supported[2].complete is False - - assert len(bound_datacenter.server_types.available) == 3 - assert bound_datacenter.server_types.available[0].id == 1 - assert bound_datacenter.server_types.available[0].complete is False - assert bound_datacenter.server_types.available[1].id == 2 - assert bound_datacenter.server_types.available[1].complete is False - assert bound_datacenter.server_types.available[2].id == 3 - assert bound_datacenter.server_types.available[2].complete is False - - assert len(bound_datacenter.server_types.available_for_migration) == 3 - assert bound_datacenter.server_types.available_for_migration[0].id == 1 - assert bound_datacenter.server_types.available_for_migration[0].complete is False - assert bound_datacenter.server_types.available_for_migration[1].id == 2 - assert bound_datacenter.server_types.available_for_migration[1].complete is False - assert bound_datacenter.server_types.available_for_migration[2].id == 3 - assert bound_datacenter.server_types.available_for_migration[2].complete is False - - -class TestDatacentersClient(object): - + with pytest.deprecated_call(): + assert isinstance(bound_datacenter.server_types, DatacenterServerTypes) + assert len(bound_datacenter.server_types.supported) == 3 + assert bound_datacenter.server_types.supported[0].id == 1 + assert bound_datacenter.server_types.supported[0].complete is False + assert bound_datacenter.server_types.supported[1].id == 2 + assert bound_datacenter.server_types.supported[1].complete is False + assert bound_datacenter.server_types.supported[2].id == 3 + assert bound_datacenter.server_types.supported[2].complete is False + + assert len(bound_datacenter.server_types.available) == 3 + assert bound_datacenter.server_types.available[0].id == 1 + assert bound_datacenter.server_types.available[0].complete is False + assert bound_datacenter.server_types.available[1].id == 2 + assert bound_datacenter.server_types.available[1].complete is False + assert bound_datacenter.server_types.available[2].id == 3 + assert bound_datacenter.server_types.available[2].complete is False + + assert len(bound_datacenter.server_types.available_for_migration) == 3 + assert bound_datacenter.server_types.available_for_migration[0].id == 1 + assert ( + bound_datacenter.server_types.available_for_migration[0].complete + is False + ) + assert bound_datacenter.server_types.available_for_migration[1].id == 2 + assert ( + bound_datacenter.server_types.available_for_migration[1].complete + is False + ) + assert bound_datacenter.server_types.available_for_migration[2].id == 3 + assert ( + bound_datacenter.server_types.available_for_migration[2].complete + is False + ) + + +class TestDatacentersClient: @pytest.fixture() - def datacenters_client(self): - return DatacentersClient(client=mock.MagicMock()) + def datacenters_client(self, client: Client): + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", category=DeprecationWarning) + return DatacentersClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + datacenters_client: DatacentersClient, + datacenter_response, + ): + request_mock.return_value = datacenter_response - def test_get_by_id(self, datacenters_client, datacenter_response): - datacenters_client._client.request.return_value = datacenter_response datacenter = datacenters_client.get_by_id(1) - datacenters_client._client.request.assert_called_with(url="/datacenters/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/datacenters/1", + ) assert datacenter._client is datacenters_client assert datacenter.id == 1 assert datacenter.name == "fsn1-dc8" - @pytest.mark.parametrize("params", [{'name': "fsn1", "page": 1, "per_page": 10}, {}]) - def test_get_list(self, datacenters_client, two_datacenters_response, params): - datacenters_client._client.request.return_value = two_datacenters_response + @pytest.mark.parametrize( + "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] + ) + 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 @@ -88,13 +127,25 @@ def test_get_list(self, datacenters_client, two_datacenters_response, params): assert datacenter2.name == "nbg1-dc3" 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 + @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) + 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 @@ -111,12 +162,23 @@ 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 assert datacenter.id == 1 diff --git a/tests/unit/datacenters/test_domain.py b/tests/unit/datacenters/test_domain.py new file mode 100644 index 00000000..fbf2b156 --- /dev/null +++ b/tests/unit/datacenters/test_domain.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import warnings + +import pytest + +from hcloud.datacenters import Datacenter, DatacenterServerTypes + +warnings.filterwarnings("ignore", category=DeprecationWarning) + + +@pytest.mark.parametrize( + "value", + [ + (Datacenter(id=1),), + (DatacenterServerTypes(available=[], available_for_migration=[], supported=[])), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/integration/actions/__init__.py b/tests/unit/deprecation/__init__.py similarity index 100% rename from tests/integration/actions/__init__.py rename to tests/unit/deprecation/__init__.py 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/integration/datacenters/__init__.py b/tests/unit/exp/__init__.py similarity index 100% rename from tests/integration/datacenters/__init__.py rename to tests/unit/exp/__init__.py 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/integration/floating_ips/__init__.py b/tests/unit/firewalls/__init__.py similarity index 100% rename from tests/integration/floating_ips/__init__.py rename to tests/unit/firewalls/__init__.py diff --git a/tests/unit/firewalls/conftest.py b/tests/unit/firewalls/conftest.py new file mode 100644 index 00000000..26f8b853 --- /dev/null +++ b/tests/unit/firewalls/conftest.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def response_create_firewall(): + return { + "firewall": { + "id": 38, + "name": "Corporate Intranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "destination_ips": [], + "protocol": "tcp", + "port": "80", + "description": None, + }, + { + "direction": "out", + "source_ips": [], + "destination_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "protocol": "tcp", + "port": "80", + "description": "allow http out", + }, + ], + "applied_to": [ + {"server": {"id": 42}, "type": "server"}, + { + "type": "label_selector", + "label_selector": {"selector": "key==value"}, + }, + ], + }, + "actions": [ + { + "command": "set_firewall_rules", + "error": {"code": "action_failed", "message": "Action failed"}, + "finished": "2016-01-30T23:56:00+00:00", + "id": 13, + "progress": 100, + "resources": [{"id": 38, "type": "firewall"}], + "started": "2016-01-30T23:55:00+00:00", + "status": "success", + }, + { + "command": "apply_firewall", + "error": {"code": "action_failed", "message": "Action failed"}, + "finished": "2016-01-30T23:56:00+00:00", + "id": 14, + "progress": 100, + "resources": [ + {"id": 42, "type": "server"}, + {"id": 38, "type": "firewall"}, + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success", + }, + ], + } + + +@pytest.fixture() +def firewall_response(): + return { + "firewall": { + "id": 38, + "name": "Corporate Intranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "destination_ips": [], + "protocol": "tcp", + "port": "80", + "description": "allow http in", + }, + { + "direction": "out", + "source_ips": [], + "destination_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "protocol": "tcp", + "port": "80", + "description": "allow http out", + }, + ], + "applied_to": [ + {"server": {"id": 42}, "type": "server"}, + { + "type": "label_selector", + "label_selector": {"selector": "key==value"}, + }, + ], + } + } + + +@pytest.fixture() +def two_firewalls_response(): + return { + "firewalls": [ + { + "id": 38, + "name": "Corporate Intranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "destination_ips": [], + "protocol": "tcp", + "port": "80", + "description": "allow http in", + } + ], + "applied_to": [{"server": {"id": 42}, "type": "server"}], + }, + { + "id": 39, + "name": "Corporate Extranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "destination_ips": [], + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "protocol": "tcp", + "port": "443", + "description": "allow https in", + } + ], + "applied_to": [{"server": {"id": 42}, "type": "server"}], + }, + ] + } + + +@pytest.fixture() +def one_firewalls_response(): + return { + "firewalls": [ + { + "id": 38, + "name": "Corporate Intranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "destination_ips": [], + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "protocol": "tcp", + "port": "80", + "description": "allow http in", + } + ], + "applied_to": [{"server": {"id": 42}, "type": "server"}], + } + ] + } + + +@pytest.fixture() +def response_update_firewall(): + return { + "firewall": { + "id": 38, + "name": "New Corporate Intranet Protection", + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "rules": [ + { + "direction": "in", + "source_ips": [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ], + "destination_ips": [], + "protocol": "tcp", + "port": "80", + "description": "allow http in", + } + ], + "applied_to": [{"server": {"id": 42}, "type": "server"}], + } + } + + +@pytest.fixture() +def response_get_actions(): + return { + "actions": [ + { + "id": 13, + "command": "set_firewall_rules", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 42, "type": "firewall"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + ] + } + + +@pytest.fixture() +def response_set_rules(): + return { + "actions": [ + { + "id": 13, + "command": "set_firewall_rules", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 38, "type": "firewall"}], + "error": {"code": "action_failed", "message": "Action failed"}, + }, + { + "id": 14, + "command": "apply_firewall", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [ + {"id": 38, "type": "firewall"}, + {"id": 42, "type": "server"}, + ], + "error": {"code": "action_failed", "message": "Action failed"}, + }, + ] + } diff --git a/tests/unit/firewalls/test_client.py b/tests/unit/firewalls/test_client.py new file mode 100644 index 00000000..79313c15 --- /dev/null +++ b/tests/unit/firewalls/test_client.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.firewalls import ( + BoundFirewall, + Firewall, + FirewallResource, + FirewallResourceLabelSelector, + FirewallRule, + FirewallsClient, +) +from hcloud.servers import Server + +from ..conftest import BoundModelTestCase + + +class TestBoundFirewall(BoundModelTestCase): + methods = [ + BoundFirewall.update, + BoundFirewall.delete, + BoundFirewall.apply_to_resources, + BoundFirewall.remove_from_resources, + BoundFirewall.set_rules, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.firewalls + + @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 + assert firewall_in_rule.port == "80" + assert isinstance(firewall_in_rule.source_ips, list) + assert len(firewall_in_rule.source_ips) == 3 + assert firewall_in_rule.source_ips == [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ] + assert isinstance(firewall_in_rule.destination_ips, list) + assert len(firewall_in_rule.destination_ips) == 0 + assert firewall_in_rule.description == "allow http in" + + 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 + assert firewall_out_rule.port == "80" + assert isinstance(firewall_out_rule.source_ips, list) + assert len(firewall_out_rule.source_ips) == 0 + assert isinstance(firewall_out_rule.destination_ips, list) + assert len(firewall_out_rule.destination_ips) == 3 + assert firewall_out_rule.destination_ips == [ + "28.239.13.1/32", + "28.239.14.0/24", + "ff21:1eac:9a3b:ee58:5ca:990c:8bc9:c03b/128", + ] + assert firewall_out_rule.description == "allow http out" + + +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, + ): + request_mock.return_value = firewall_response + + firewall = firewalls_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/firewalls/1", + ) + assert firewall._client is firewalls_client + assert firewall.id == 38 + assert firewall.name == "Corporate Intranet Protection" + + @pytest.mark.parametrize( + "params", + [ + { + "name": "Corporate Intranet Protection", + "sort": "id", + "label_selector": "k==v", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + 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) + + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, + ) + + firewalls = result.firewalls + assert result.meta is not None + + assert len(firewalls) == 2 + + firewalls1 = firewalls[0] + firewalls2 = firewalls[1] + + assert firewalls1._client is firewalls_client + assert firewalls1.id == 38 + assert firewalls1.name == "Corporate Intranet Protection" + + assert firewalls2._client is firewalls_client + assert firewalls2.id == 39 + assert firewalls2.name == "Corporate Extranet Protection" + + @pytest.mark.parametrize( + "params", + [ + { + "name": "Corporate Intranet Protection", + "sort": "id", + "label_selector": "k==v", + }, + {}, + ], + ) + 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}) + + request_mock.assert_called_with( + method="GET", + url="/firewalls", + params=params, + ) + + assert len(firewalls) == 2 + + firewalls1 = firewalls[0] + firewalls2 = firewalls[1] + + assert firewalls1._client is firewalls_client + assert firewalls1.id == 38 + assert firewalls1.name == "Corporate Intranet Protection" + + assert firewalls2._client is firewalls_client + assert firewalls2.id == 39 + assert firewalls2.name == "Corporate Extranet Protection" + + 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"} + + 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" + + def test_create( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + response_create_firewall, + ): + request_mock.return_value = response_create_firewall + + response = firewalls_client.create( + "Corporate Intranet Protection", + rules=[ + FirewallRule( + direction=FirewallRule.DIRECTION_IN, + protocol=FirewallRule.PROTOCOL_ICMP, + source_ips=["0.0.0.0/0"], + ) + ], + resources=[ + FirewallResource( + type=FirewallResource.TYPE_SERVER, server=Server(id=4711) + ), + FirewallResource( + type=FirewallResource.TYPE_LABEL_SELECTOR, + label_selector=FirewallResourceLabelSelector(selector="key==value"), + ), + ], + ) + + request_mock.assert_called_with( + method="POST", + url="/firewalls", + json={ + "name": "Corporate Intranet Protection", + "rules": [ + {"direction": "in", "protocol": "icmp", "source_ips": ["0.0.0.0/0"]} + ], + "apply_to": [ + {"type": "server", "server": {"id": 4711}}, + { + "type": "label_selector", + "label_selector": {"selector": "key==value"}, + }, + ], + }, + ) + + bound_firewall = response.firewall + actions = response.actions + + assert bound_firewall._client is firewalls_client + assert bound_firewall.id == 38 + assert bound_firewall.name == "Corporate Intranet Protection" + assert len(bound_firewall.applied_to) == 2 + + assert len(actions) == 2 + + @pytest.mark.parametrize( + "firewall", [Firewall(id=38), BoundFirewall(mock.MagicMock(), dict(id=38))] + ) + 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={} + ) + + request_mock.assert_called_with( + method="PUT", + url="/firewalls/38", + json={"name": "New Corporate Intranet Protection", "labels": {}}, + ) + + assert firewall.id == 38 + assert firewall.name == "New Corporate Intranet Protection" + + @pytest.mark.parametrize( + "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] + ) + 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, + [ + FirewallRule( + 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", + ), + ], + ) + + 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", + }, + ] + }, + ) + + assert actions[0].id == 13 + assert actions[0].progress == 100 + + @pytest.mark.parametrize( + "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] + ) + def test_delete( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + ): + delete_success = firewalls_client.delete(firewall) + + request_mock.assert_called_with( + method="DELETE", + url="/firewalls/1", + ) + + assert delete_success is True + + @pytest.mark.parametrize( + "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] + ) + 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))], + ) + + request_mock.assert_called_with( + method="POST", + url="/firewalls/1/actions/apply_to_resources", + json={"apply_to": [{"type": "server", "server": {"id": 5}}]}, + ) + + assert actions[0].id == 13 + assert actions[0].progress == 100 + + @pytest.mark.parametrize( + "firewall", [Firewall(id=1), BoundFirewall(mock.MagicMock(), dict(id=1))] + ) + def test_remove_from_resources( + self, + request_mock: mock.MagicMock, + firewalls_client: FirewallsClient, + firewall, + 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))], + ) + + request_mock.assert_called_with( + method="POST", + url="/firewalls/1/actions/remove_from_resources", + json={"remove_from": [{"type": "server", "server": {"id": 5}}]}, + ) + + assert actions[0].id == 13 + assert actions[0].progress == 100 diff --git a/tests/unit/firewalls/test_domain.py b/tests/unit/firewalls/test_domain.py new file mode 100644 index 00000000..7924fbb7 --- /dev/null +++ b/tests/unit/firewalls/test_domain.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import datetime +from datetime import timezone + +import pytest + +from hcloud.firewalls import ( + Firewall, + FirewallResource, + FirewallResourceAppliedToResources, + FirewallResourceLabelSelector, + FirewallRule, +) + + +@pytest.mark.parametrize( + "value", + [ + (Firewall(id=1),), + (FirewallRule(direction="in", protocol="icmp", source_ips=[]),), + (FirewallResource(type="server"),), + (FirewallResourceAppliedToResources(type="server"),), + (FirewallResourceLabelSelector(),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestFirewall: + 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=timezone.utc + ) diff --git a/tests/unit/floating_ips/conftest.py b/tests/unit/floating_ips/conftest.py index 3a3e5df0..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 @@ -7,16 +9,12 @@ def floating_ip_response(): "floating_ip": { "id": 4711, "description": "Web Frontend", + "name": "Web Frontend", "created": "2016-01-30T23:50+00:00", "ip": "131.232.99.1", "type": "ipv4", "server": 42, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ], + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], "home_location": { "id": 1, "name": "fsn1", @@ -24,34 +22,28 @@ def floating_ip_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "blocked": False, - "protection": { - "delete": False - }, - "labels": {} + "protection": {"delete": False}, + "labels": {}, } } @pytest.fixture() -def two_floating_ips_response(): +def one_floating_ips_response(): return { "floating_ips": [ { "id": 4711, "description": "Web Frontend", + "name": "Web Frontend", "created": "2016-01-30T23:50+00:00", "ip": "131.232.99.1", "type": "ipv4", "server": 42, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ], + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], "home_location": { "id": 1, "name": "fsn1", @@ -59,27 +51,51 @@ def two_floating_ips_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "blocked": False, - "protection": { - "delete": False + "protection": {"delete": False}, + "labels": {}, + } + ] + } + + +@pytest.fixture() +def two_floating_ips_response(): + return { + "floating_ips": [ + { + "id": 4711, + "description": "Web Frontend", + "name": "Web Frontend", + "created": "2016-01-30T23:50+00:00", + "ip": "131.232.99.1", + "type": "ipv4", + "server": 42, + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], + "home_location": { + "id": 1, + "name": "fsn1", + "description": "Falkenstein DC Park 1", + "country": "DE", + "city": "Falkenstein", + "latitude": 50.47612, + "longitude": 12.370071, }, - "labels": {} + "blocked": False, + "protection": {"delete": False}, + "labels": {}, }, { "id": 4712, "description": "Web Backend", + "name": "Web Backend", "created": "2016-01-30T23:50+00:00", "ip": "131.232.99.2", "type": "ipv4", "server": 42, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ], + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], "home_location": { "id": 1, "name": "fsn1", @@ -87,14 +103,12 @@ def two_floating_ips_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "blocked": False, - "protection": { - "delete": False - }, - "labels": {} - } + "protection": {"delete": False}, + "labels": {}, + }, ] } @@ -105,16 +119,12 @@ def floating_ip_create_response(): "floating_ip": { "id": 4711, "description": "Web Frontend", + "name": "Web Frontend", "created": "2016-01-30T23:50+00:00", "ip": "131.232.99.1", "type": "ipv4", "server": 42, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ], + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], "home_location": { "id": 1, "name": "fsn1", @@ -122,13 +132,11 @@ def floating_ip_create_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "blocked": False, - "protection": { - "delete": False - }, - "labels": {} + "protection": {"delete": False}, + "labels": {}, }, "action": { "id": 13, @@ -137,17 +145,9 @@ def floating_ip_create_response(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, + }, } @@ -155,19 +155,14 @@ def floating_ip_create_response(): def response_update_floating_ip(): return { "floating_ip": { - "id": 4711, "description": "New description", + "name": "New name", "created": "2016-01-30T23:50+00:00", "ip": "131.232.99.1", "type": "ipv4", "server": 42, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ], + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], "home_location": { "id": 1, "name": "fsn1", @@ -175,13 +170,11 @@ def response_update_floating_ip(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "blocked": False, - "protection": { - "delete": False - }, - "labels": {} + "protection": {"delete": False}, + "labels": {}, } } @@ -197,16 +190,8 @@ def response_get_actions(): "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" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, } ] } diff --git a/tests/unit/floating_ips/test_client.py b/tests/unit/floating_ips/test_client.py index fc49fc37..cc271c66 100644 --- a/tests/unit/floating_ips/test_client.py +++ b/tests/unit/floating_ips/test_client.py @@ -1,140 +1,127 @@ -import pytest -import mock +from __future__ import annotations -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 +from unittest import mock +import pytest -class TestBoundFloatingIP(object): +from hcloud import Client +from hcloud.floating_ips import BoundFloatingIP, FloatingIP, FloatingIPsClient +from hcloud.locations import BoundLocation, Location +from hcloud.servers import BoundServer, Server - @pytest.fixture() - def bound_floating_ip(self, hetzner_client): - return BoundFloatingIP(client=hetzner_client.floating_ips, data=dict(id=14)) +from ..conftest import BoundModelTestCase - def test_bound_floating_ip_init(self, floating_ip_response): - bound_floating_ip = BoundFloatingIP( - client=mock.MagicMock(), - data=floating_ip_response['floating_ip'] - ) - assert bound_floating_ip.id == 4711 - assert bound_floating_ip.description == "Web Frontend" - assert bound_floating_ip.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") - hetzner_client.request.assert_called_with(url="/floating_ips/14", method="PUT", - json={"description": "New description"}) +class TestBoundFloatingIP(BoundModelTestCase): + methods = [ + BoundFloatingIP.update, + BoundFloatingIP.delete, + BoundFloatingIP.change_protection, + BoundFloatingIP.change_dns_ptr, + BoundFloatingIP.assign, + BoundFloatingIP.unassign, + ] - assert floating_ip.id == 4711 - assert floating_ip.description == "New description" - - def test_delete(self, hetzner_client, bound_floating_ip, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_floating_ip.delete() - hetzner_client.request.assert_called_with(url="/floating_ips/14", method="DELETE") - - assert delete_success is True + @pytest.fixture() + def resource_client(self, client: Client): + return client.floating_ips - 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}) + @pytest.fixture() + def bound_model(self, resource_client, floating_ip_response): + return BoundFloatingIP( + resource_client, data=floating_ip_response["floating_ip"] + ) - assert action.id == 1 - assert action.progress == 0 + def test_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, client: Client): + return FloatingIPsClient(client) - @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 + 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_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 + bound_floating_ip = floating_ips_client.get_by_id(1) - 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"} + request_mock.assert_called_with( + method="GET", + url="/floating_ips/1", ) - assert action.id == 1 - assert action.progress == 0 - + assert bound_floating_ip._client is floating_ips_client + assert bound_floating_ip.id == 4711 + assert bound_floating_ip.description == "Web Frontend" -class TestFloatingIPsClient(object): + 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 - @pytest.fixture() - def floating_ips_client(self): - return FloatingIPsClient(client=mock.MagicMock()) + bound_floating_ip = floating_ips_client.get_by_name("Web Frontend") - 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", + params={"name": "Web Frontend"}, + ) assert bound_floating_ip._client is floating_ips_client assert bound_floating_ip.id == 4711 + assert bound_floating_ip.name == "Web Frontend" assert bound_floating_ip.description == "Web Frontend" - @pytest.mark.parametrize("params", [{'label_selector': "label1", 'page': 1, 'per_page': 10}, {}]) - def test_get_list(self, floating_ips_client, two_floating_ips_response, params): - floating_ips_client._client.request.return_value = two_floating_ips_response + @pytest.mark.parametrize( + "params", + [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], + ) + 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 @@ -149,14 +136,25 @@ def test_get_list(self, floating_ips_client, two_floating_ips_response, params): assert bound_floating_ip2.id == 4712 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 + @pytest.mark.parametrize("params", [{"label_selector": "label1"}, {}]) + 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}) + 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 @@ -171,21 +169,26 @@ 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", - 'home_location': "location" - } + "description": "Web Frontend", + "type": "ipv6", + "home_location": "location", + }, ) bound_floating_ip = response.floating_ip @@ -196,22 +199,26 @@ def test_create_with_location(self, floating_ips_client, floating_ip_response): assert bound_floating_ip.description == "Web Frontend" assert action is None - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_create_with_server(self, floating_ips_client, server, floating_ip_create_response): - floating_ips_client._client.request.return_value = floating_ip_create_response + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_create_with_server( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + server, + floating_ip_create_response, + ): + request_mock.return_value = floating_ip_create_response + response = floating_ips_client.create( - type="ipv6", - description="Web Frontend", - server=server + 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", - json={ - 'description': "Web Frontend", - 'type': "ipv6", - 'server': 1 - } + url="/floating_ips", + json={"description": "Web Frontend", "type": "ipv6", "server": 1}, ) bound_floating_ip = response.floating_ip action = response.action @@ -221,82 +228,179 @@ def test_create_with_server(self, floating_ips_client, server, floating_ip_creat assert bound_floating_ip.description == "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}) + def test_create_with_name( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip_create_response, + ): + request_mock.return_value = floating_ip_create_response - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) + response = floating_ips_client.create( + type="ipv6", description="Web Frontend", name="Web Frontend" + ) + + request_mock.assert_called_with( + method="POST", + url="/floating_ips", + json={ + "description": "Web Frontend", + "type": "ipv6", + "name": "Web Frontend", + }, + ) + bound_floating_ip = response.floating_ip + action = response.action + + assert bound_floating_ip._client is floating_ips_client + assert bound_floating_ip.id == 4711 + assert bound_floating_ip.description == "Web Frontend" + assert bound_floating_ip.name == "Web Frontend" + assert action.id == 13 - 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, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + 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" + ) - @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): - floating_ips_client._client.request.return_value = response_update_floating_ip - floating_ip = floating_ips_client.update(floating_ip, description="New description") - floating_ips_client._client.request.assert_called_with(url="/floating_ips/1", method="PUT", - json={"description": "New description"}) + request_mock.assert_called_with( + method="PUT", + url="/floating_ips/1", + json={"description": "New description", "name": "New name"}, + ) assert floating_ip.id == 4711 assert floating_ip.description == "New description" + assert floating_ip.name == "New name" + + @pytest.mark.parametrize( + "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + floating_ips_client: FloatingIPsClient, + floating_ip, + action_response, + ): + request_mock.return_value = action_response - @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 action = floating_ips_client.change_protection(floating_ip, True) - floating_ips_client._client.request.assert_called_with(url="/floating_ips/1/actions/change_protection", - method="POST", json={"delete": True}) + + request_mock.assert_called_with( + method="POST", + url="/floating_ips/1/actions/change_protection", + json={"delete": True}, + ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "floating_ip", [FloatingIP(id=1), BoundFloatingIP(mock.MagicMock(), dict(id=1))] + ) + 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 - @pytest.mark.parametrize("server,floating_ip", - [(Server(id=1), FloatingIP(id=12)), - (BoundServer(mock.MagicMock(), dict(id=1)), - BoundFloatingIP(mock.MagicMock(), dict(id=12)))]) - def test_assign(self, floating_ips_client, server, floating_ip, generic_action): - floating_ips_client._client.request.return_value = generic_action + @pytest.mark.parametrize( + "server,floating_ip", + [ + (Server(id=1), FloatingIP(id=12)), + ( + BoundServer(mock.MagicMock(), dict(id=1)), + BoundFloatingIP(mock.MagicMock(), dict(id=12)), + ), + ], + ) + 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", + + request_mock.assert_called_with( method="POST", - json={"server": 1} + url="/floating_ips/12/actions/assign", + json={"server": 1}, ) assert action.id == 1 assert action.progress == 0 - @pytest.mark.parametrize("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 + @pytest.mark.parametrize( + "floating_ip", + [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], + ) + 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( + + request_mock.assert_called_with( + method="POST", url="/floating_ips/12/actions/unassign", - method="POST" ) assert action.id == 1 assert action.progress == 0 - @pytest.mark.parametrize("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 - 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", + @pytest.mark.parametrize( + "floating_ip", + [FloatingIP(id=12), BoundFloatingIP(mock.MagicMock(), dict(id=12))], + ) + 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" + ) + + request_mock.assert_called_with( method="POST", - json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"} + url="/floating_ips/12/actions/change_dns_ptr", + json={"ip": "1.2.3.4", "dns_ptr": "server02.example.com"}, ) assert action.id == 1 assert action.progress == 0 diff --git a/tests/unit/floating_ips/test_domain.py b/tests/unit/floating_ips/test_domain.py index 5d96aa1b..98be19e6 100644 --- a/tests/unit/floating_ips/test_domain.py +++ b/tests/unit/floating_ips/test_domain.py @@ -1,11 +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_descriptors.py b/tests/unit/helpers/test_descriptors.py deleted file mode 100644 index 478bd2e2..00000000 --- a/tests/unit/helpers/test_descriptors.py +++ /dev/null @@ -1,36 +0,0 @@ -import pytest -import datetime -from dateutil.tz import tzoffset - -from hcloud.helpers.descriptors import ISODateTime - - -class TestISODateTime(object): - - @pytest.fixture() - def entity_with_descriptor(self): - - class TestDesctiptorISODateTime(object): - created = ISODateTime() - - def __init__(self, created=None): - self.created = created - - return TestDesctiptorISODateTime - - def test_created_none(self, entity_with_descriptor): - entity = entity_with_descriptor() - assert entity.created is None - - def test_created_assigned_valid(self, entity_with_descriptor): - entity = entity_with_descriptor() - entity.created = "2016-01-30T23:50+00:00" - assert entity.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0)) - - def test_created_valid(self, entity_with_descriptor): - entity = entity_with_descriptor(created="2016-01-30T23:50+00:00") - assert entity.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0)) - - def test_created_invalid(self, entity_with_descriptor): - with pytest.raises(ValueError): - entity_with_descriptor(created="invalid") 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 d8d5d67e..0917fbae 100644 --- a/tests/unit/images/conftest.py +++ b/tests/unit/images/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -8,24 +10,20 @@ def image_response(): "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": 1, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, } } @@ -38,24 +36,20 @@ def two_images_response(): "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, }, { "id": 4712, @@ -66,20 +60,16 @@ def two_images_response(): "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} - } + "labels": {}, + }, ] } @@ -92,25 +82,21 @@ def one_images_response(): "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "x86", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} - }, + "labels": {}, + } ] } @@ -127,19 +113,15 @@ def response_update_image(): "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", + "architecture": "arm", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, } } @@ -155,16 +137,8 @@ def response_get_actions(): "progress": 100, "started": "2016-01-30T23:55:00+00:00", "finished": "2016-01-30T23:56:00+00:00", - "resources": [ - { - "id": 42, - "type": "image" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "image"}], + "error": {"code": "action_failed", "message": "Action failed"}, } ] } diff --git a/tests/unit/images/test_client.py b/tests/unit/images/test_client.py index 85ca583e..d88b36bf 100644 --- a/tests/unit/images/test_client.py +++ b/tests/unit/images/test_client.py @@ -1,38 +1,53 @@ -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 bound_image(self, hetzner_client): - return BoundImage(client=hetzner_client.images, data=dict(id=14)) + def resource_client(self, client: Client): + return client.images - def test_bound_image_init(self, image_response): - bound_image = BoundImage( - client=mock.MagicMock(), - data=image_response['image'] - ) + @pytest.fixture() + def bound_model(self, resource_client): + return BoundImage(resource_client, data=dict(id=14)) + + def test_init(self, image_response): + bound_image = BoundImage(client=mock.MagicMock(), data=image_response["image"]) assert bound_image.id == 4711 assert bound_image.type == "snapshot" assert bound_image.status == "available" - assert bound_image.name == "ubuntu-16.04" - assert bound_image.description == "Ubuntu 16.04 Standard 64 bit" + assert bound_image.name == "ubuntu-20.04" + assert bound_image.description == "Ubuntu 20.04 Standard 64 bit" assert bound_image.image_size == 2.3 assert bound_image.disk_size == 10 - assert bound_image.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0)) + assert bound_image.created == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) assert bound_image.os_flavor == "ubuntu" assert bound_image.os_version == "16.04" + assert bound_image.architecture == "x86" assert bound_image.rapid_deploy is False - assert bound_image.deprecated == datetime.datetime(2018, 2, 28, 0, 0, tzinfo=tzoffset(None, 0)) + assert bound_image.deprecated == datetime.datetime( + 2018, 2, 28, 0, 0, tzinfo=timezone.utc + ) assert isinstance(bound_image.created_from, BoundServer) assert bound_image.created_from.id == 1 @@ -43,108 +58,66 @@ 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): - 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" - - def test_update(self, hetzner_client, bound_image, response_update_image): - hetzner_client.request.return_value = response_update_image - image = bound_image.update(description="My new Image description", type="snapshot", labels={}) - hetzner_client.request.assert_called_with(url="/images/14", method="PUT", json={"description": "My new Image description", "type": "snapshot", "labels": {}}) - - assert image.id == 4711 - assert image.description == "My new Image description" - - def test_delete(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action - delete_success = bound_image.delete() - hetzner_client.request.assert_called_with(url="/images/14", method="DELETE") - - assert delete_success is True - - def test_change_protection(self, hetzner_client, bound_image, generic_action): - hetzner_client.request.return_value = generic_action - action = bound_image.change_protection(True) - hetzner_client.request.assert_called_with(url="/images/14/actions/change_protection", method="POST", json={"delete": True}) - - assert action.id == 1 - assert action.progress == 0 - - -class TestImagesClient(object): +class TestImagesClient: @pytest.fixture() - def images_client(self): - return ImagesClient(client=mock.MagicMock()) + def images_client(self, client: Client): + return ImagesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + image_response, + ): + request_mock.return_value = image_response - def test_get_by_id(self, images_client, image_response): - images_client._client.request.return_value = image_response image = images_client.get_by_id(1) - images_client._client.request.assert_called_with(url="/images/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/images/1", + ) assert image._client is images_client assert image.id == 4711 - assert image.name == "ubuntu-16.04" + assert image.name == "ubuntu-20.04" @pytest.mark.parametrize( "params", [ { - 'name': "ubuntu-16.04", + "name": "ubuntu-20.04", "type": "system", "sort": "id", "bound_to": "1", "label_selector": "k==v", "page": 1, - "per_page": 10 + "per_page": 10, }, - {} - ] + {"name": ""}, + {"include_deprecated": True}, + {}, + ], ) - 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 @@ -153,7 +126,7 @@ def test_get_list(self, images_client, two_images_response, params): assert images1._client is images_client assert images1.id == 4711 - assert images1.name == "ubuntu-16.04" + assert images1.name == "ubuntu-20.04" assert images2._client is images_client assert images2.id == 4712 @@ -163,22 +136,34 @@ def test_get_list(self, images_client, two_images_response, params): "params", [ { - 'name': "ubuntu-16.04", + "name": "ubuntu-20.04", "type": "system", "sort": "id", "bound_to": "1", "label_selector": "k==v", }, - {} - ] + {"include_deprecated": True}, + {}, + ], ) - 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 @@ -187,62 +172,127 @@ def test_get_all(self, images_client, two_images_response, params): assert images1._client is images_client assert images1.id == 4711 - assert images1.name == "ubuntu-16.04" + assert images1.name == "ubuntu-20.04" assert images2._client is images_client 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-16.04") + def test_get_by_name( + self, + request_mock: mock.MagicMock, + images_client: ImagesClient, + one_images_response, + ): + request_mock.return_value = one_images_response - params = {"name": "ubuntu-16.04"} + with pytest.deprecated_call(): + image = images_client.get_by_name("ubuntu-20.04") - images_client._client.request.assert_called_with(url="/images", method="GET", params=params) + params = {"name": "ubuntu-20.04"} + + 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-16.04" + 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, + ) - @pytest.mark.parametrize("image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))]) - def test_update(self, images_client, image, response_update_image): - images_client._client.request.return_value = response_update_image - image = images_client.update(image, description="My new Image description", type="snapshot", labels={}) - images_client._client.request.assert_called_with(url="/images/1", method="PUT", json={"description": "My new Image description", "type": "snapshot", "labels": {}}) + 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, + 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={} + ) + + request_mock.assert_called_with( + method="PUT", + url="/images/1", + json={ + "description": "My new Image description", + "type": "snapshot", + "labels": {}, + }, + ) assert image.id == 4711 assert image.description == "My new Image description" - @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 + @pytest.mark.parametrize( + "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] + ) + 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", method="POST", json={"delete": True}) + + request_mock.assert_called_with( + method="POST", + url="/images/1/actions/change_protection", + json={"delete": True}, + ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "image", [Image(id=1), BoundImage(mock.MagicMock(), dict(id=1))] + ) + 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 6df24e25..4ff6c133 100644 --- a/tests/unit/images/test_domain.py +++ b/tests/unit/images/test_domain.py @@ -1,11 +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)) + assert image.created == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) diff --git a/tests/unit/isos/conftest.py b/tests/unit/isos/conftest.py index f45aab7d..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", - "deprecated": "2018-02-28T00:00:00+00:00" + "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,15 +30,21 @@ def two_isos_response(): "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": "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", - "deprecated": "2018-02-28T00:00:00+00:00" + "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 3a97d017..3735f26d 100644 --- a/tests/unit/isos/test_client.py +++ b/tests/unit/isos/test_client.py @@ -1,40 +1,59 @@ -import pytest -import mock +from __future__ import annotations + import datetime -from dateutil.tz import tzoffset +from datetime import timezone +from unittest import mock -from hcloud.isos.client import IsosClient, BoundIso +import pytest +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'] - ) + bound_iso = BoundIso(client=mock.MagicMock(), data=iso_response["iso"]) assert bound_iso.id == 4711 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" @@ -43,18 +62,29 @@ def test_get_by_id(self, isos_client, iso_response): "params", [ {}, - {'name': "FreeBSD-11.0-RELEASE-amd64-dvd1", - 'page': 1, - 'per_page': 2} - ] + {"name": ""}, + {"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,19 +100,26 @@ def test_get_list(self, isos_client, two_isos_response, params): assert isos2.name == "FreeBSD-11.0-RELEASE-amd64-dvd1" @pytest.mark.parametrize( - "params", - [ - {}, - {'name': "FreeBSD-11.0-RELEASE-amd64-dvd1"} - ] + "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}) + 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 @@ -97,13 +134,23 @@ 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"} + 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 assert iso.id == 4711 diff --git a/tests/unit/isos/test_domain.py b/tests/unit/isos/test_domain.py index af1377a6..f6c72a15 100644 --- a/tests/unit/isos/test_domain.py +++ b/tests/unit/isos/test_domain.py @@ -1,11 +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): +from hcloud.isos import Iso - 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)) + +@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/integration/images/__init__.py b/tests/unit/load_balancer_types/__init__.py similarity index 100% rename from tests/integration/images/__init__.py rename to tests/unit/load_balancer_types/__init__.py diff --git a/tests/unit/load_balancer_types/conftest.py b/tests/unit/load_balancer_types/conftest.py new file mode 100644 index 00000000..f0a2ec5b --- /dev/null +++ b/tests/unit/load_balancer_types/conftest.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def load_balancer_type1(): + return { + "id": 1, + "name": "lb11", + "description": "LB11", + "max_connections": 10000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"net": "0.0120", "gross": "0.0120"}, + "price_monthly": {"net": "7.4900", "gross": "7.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, + }, + ], + } + + +@pytest.fixture() +def load_balancer_type2(): + return { + "id": 2, + "name": "lb21", + "description": "LB21", + "max_connections": 20000, + "max_services": 15, + "max_targets": 75, + "max_assigned_certificates": 25, + "deprecation": None, + "prices": [ + { + "location": "fsn1", + "price_hourly": {"net": "0.0344", "gross": "0.0344"}, + "price_monthly": {"net": "21.4900", "gross": "21.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, + }, + ], + } diff --git a/tests/unit/load_balancer_types/test_client.py b/tests/unit/load_balancer_types/test_client.py new file mode 100644 index 00000000..3c774a30 --- /dev/null +++ b/tests/unit/load_balancer_types/test_client.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.load_balancer_types import LoadBalancerTypesClient + + +class TestLoadBalancerTypesClient: + @pytest.fixture() + def load_balancer_types_client(self, client: Client): + return LoadBalancerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + ): + request_mock.return_value = { + "load_balancer_type": load_balancer_type1, + } + + result = load_balancer_types_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types/1", + ) + + assert result._client is load_balancer_types_client + assert result.id == 1 + assert result.name == "lb11" + assert result.description == "LB11" + assert result.max_connections == 10000 + assert result.max_services == 5 + assert result.max_targets == 25 + assert result.max_assigned_certificates == 10 + assert result.prices == [ + { + "location": "fsn1", + "price_hourly": {"net": "0.0120", "gross": "0.0120"}, + "price_monthly": {"net": "7.4900", "gross": "7.4900"}, + "price_per_tb_traffic": {"net": "1.0000", "gross": "1.0000"}, + "included_traffic": 21990232555520, + } + ] + assert result.deprecation.announced == isoparse("2023-06-01T00:00:00+00:00") + assert result.deprecation.unavailable_after == isoparse( + "2023-09-01T00:00:00+00:00" + ) + + @pytest.mark.parametrize( + "params", [{"name": "lb11", "page": 1, "per_page": 10}, {"name": ""}, {}] + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + load_balancer_type2, + params, + ): + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2], + } + + result = load_balancer_types_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, + ) + + assert result.meta is not None + assert len(result.load_balancer_types) == 2 + + assert result.load_balancer_types[0]._client is load_balancer_types_client + assert result.load_balancer_types[0].id == 1 + assert result.load_balancer_types[0].name == "lb11" + + assert result.load_balancer_types[1]._client is load_balancer_types_client + assert result.load_balancer_types[1].id == 2 + assert result.load_balancer_types[1].name == "lb21" + + @pytest.mark.parametrize("params", [{"name": "lb11"}]) + def test_get_all( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + load_balancer_type2, + params, + ): + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1, load_balancer_type2] + } + + result = load_balancer_types_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params=params, + ) + + assert len(result) == 2 + + assert result[0]._client is load_balancer_types_client + assert result[0].id == 1 + assert result[0].name == "lb11" + + assert result[1]._client is load_balancer_types_client + assert result[1].id == 2 + assert result[1].name == "lb21" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + load_balancer_types_client: LoadBalancerTypesClient, + load_balancer_type1, + ): + request_mock.return_value = { + "load_balancer_types": [load_balancer_type1], + } + + result = load_balancer_types_client.get_by_name("lb11") + + request_mock.assert_called_with( + method="GET", + url="/load_balancer_types", + params={"name": "lb11"}, + ) + + assert result._client is load_balancer_types_client + assert result.id == 1 + assert result.name == "lb11" diff --git a/tests/unit/load_balancer_types/test_domain.py b/tests/unit/load_balancer_types/test_domain.py new file mode 100644 index 00000000..bbd0129c --- /dev/null +++ b/tests/unit/load_balancer_types/test_domain.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import pytest + +from hcloud.load_balancer_types import LoadBalancerType + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancerType(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) diff --git a/tests/integration/isos/__init__.py b/tests/unit/load_balancers/__init__.py similarity index 100% rename from tests/integration/isos/__init__.py rename to tests/unit/load_balancers/__init__.py diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py new file mode 100644 index 00000000..d7fe2a90 --- /dev/null +++ b/tests/unit/load_balancers/conftest.py @@ -0,0 +1,695 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def response_load_balancer(): + return { + "load_balancer": { + "id": 4711, + "name": "Web Frontend", + "ipv4": "131.232.99.1", + "ipv6": "2001:db8::1", + "location": { + "id": 1, + "name": "fsn1", + "description": "Falkenstein DC Park 1", + "country": "DE", + "city": "Falkenstein", + "latitude": 50.47612, + "longitude": 12.370071, + "network_zone": "eu-central", + }, + "load_balancer_type": { + "id": 1, + "name": "lb11", + "description": "lb11", + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecated": "2016-01-30T23:50:00+00:00", + "prices": [ + { + "location": "fsn-1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } + ], + }, + "protection": {"delete": False}, + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "services": [ + { + "protocol": "https", + "listen_port": 443, + "destination_port": 80, + "proxyprotocol": False, + "http": { + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "certificates": [897], + "redirect_http": True, + "sticky_sessions": True, + "timeout_idle": 60, + }, + "health_check": { + "protocol": "http", + "port": 4711, + "interval": 15, + "timeout": 10, + "retries": 3, + "http": { + "domain": "example.com", + "path": "/", + "response": '{"status": "ok"}', + "status_codes": [200], + "tls": False, + }, + }, + } + ], + "targets": [ + { + "type": "server", + "server": {"id": 80}, + "health_status": [{"listen_port": 443, "status": "healthy"}], + "label_selector": None, + "use_private_ip": False, + }, + { + "type": "label_selector", + "label_selector": {"selector": "env=prod"}, + "use_private_ip": True, + "targets": [ + { + "type": "server", + "server": {"id": 105054278}, + "use_private_ip": True, + "health_status": [ + {"listen_port": 443, "status": "healthy"}, + {"listen_port": 3000, "status": "healthy"}, + ], + } + ], + }, + ], + "algorithm": {"type": "round_robin"}, + } + } + + +@pytest.fixture() +def response_create_load_balancer(): + return { + "load_balancer": { + "id": 1, + "name": "my-balancer", + "load_balancer_type": { + "id": 1, + "name": "lb11", + "description": "lb11", + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecated": "2016-01-30T23:50:00+00:00", + "prices": [ + { + "location": "fsn-1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } + ], + }, + "network_zone": "eu-central", + "algorithm": {"type": "round_robin"}, + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "services": [ + { + "protocol": "https", + "listen_port": 443, + "destination_port": 80, + "proxyprotocol": False, + "http": { + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "certificates": [897], + "redirect_http": True, + "sticky_sessions": True, + "timeout_idle": 60, + }, + "health_check": { + "protocol": "http", + "port": 4711, + "interval": 15, + "timeout": 10, + "retries": 3, + "http": { + "domain": "example.com", + "path": "/", + "response": '{"status": "ok"}', + "status_codes": [200], + "tls": False, + }, + }, + } + ], + "targets": [ + { + "type": "server", + "server": {"id": 80}, + "label_selector": None, + "use_private_ip": False, + } + ], + }, + "action": { + "id": 1, + "command": "create_load_balancer", + "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"}, + }, + } + + +@pytest.fixture() +def response_update_load_balancer(): + return { + "load_balancer": { + "id": 4711, + "name": "new-name", + "ipv4": "131.232.99.1", + "ipv6": "2001:db8::1", + "location": { + "id": 1, + "name": "fsn1", + "description": "Falkenstein DC Park 1", + "country": "DE", + "city": "Falkenstein", + "latitude": 50.47612, + "longitude": 12.370071, + "network_zone": "eu-central", + }, + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "load_balancer_type": { + "id": 1, + "name": "lb11", + "description": "lb11", + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecated": "2016-01-30T23:50:00+00:00", + "prices": [ + { + "location": "fsn-1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } + ], + }, + "protection": {"delete": False}, + "labels": {"labelkey": "value"}, + "created": "2016-01-30T23:50:00+00:00", + "services": [ + { + "protocol": "https", + "listen_port": 443, + "destination_port": 80, + "proxyprotocol": False, + "http": { + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "certificates": [897], + "redirect_http": True, + "sticky_sessions": True, + "timeout_idle": 60, + }, + "health_check": { + "protocol": "http", + "port": 4711, + "interval": 15, + "timeout": 10, + "retries": 3, + "http": { + "domain": "example.com", + "path": "/", + "response": '{"status": "ok"}', + "status_codes": [200], + "tls": False, + }, + }, + } + ], + "targets": [ + { + "type": "server", + "server": {"id": 80}, + "use_private_ip": False, + "health_status": [{"listen_port": 443, "status": "healthy"}], + "label_selector": None, + } + ], + "algorithm": {"type": "round_robin"}, + } + } + + +@pytest.fixture() +def response_simple_load_balancers(): + return { + "load_balancers": [ + { + "id": 4711, + "name": "Web Frontend", + "ipv4": "131.232.99.1", + "ipv6": "2001:db8::1", + "location": { + "id": 1, + "name": "fsn1", + "description": "Falkenstein DC Park 1", + "country": "DE", + "city": "Falkenstein", + "latitude": 50.47612, + "longitude": 12.370071, + "network_zone": "eu-central", + }, + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "load_balancer_type": { + "id": 1, + "name": "lb11", + "description": "lb11", + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecated": "2016-01-30T23:50:00+00:00", + "prices": [ + { + "location": "fsn-1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } + ], + }, + "protection": {"delete": False}, + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "services": [ + { + "protocol": "https", + "listen_port": 443, + "destination_port": 80, + "proxyprotocol": False, + "http": { + "sticky_sessions": True, + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "certificates": [897], + "redirect_http": True, + "timeout_idle": 60, + }, + "health_check": { + "protocol": "http", + "port": 4711, + "interval": 15, + "timeout": 10, + "retries": 3, + "http": { + "domain": "example.com", + "path": "/", + "response": '{"status": "ok"}', + "status_codes": [200], + "tls": False, + }, + }, + } + ], + "targets": [ + { + "type": "server", + "server": {"id": 80}, + "use_private_ip": False, + "health_status": [{"listen_port": 443, "status": "healthy"}], + "label_selector": None, + } + ], + "algorithm": {"type": "round_robin"}, + }, + { + "id": 4712, + "name": "Web Frontend2", + "ipv4": "131.232.99.1", + "ipv6": "2001:db8::1", + "location": { + "id": 1, + "name": "fsn1", + "description": "Falkenstein DC Park 1", + "country": "DE", + "city": "Falkenstein", + "latitude": 50.47612, + "longitude": 12.370071, + "network_zone": "eu-central", + }, + "load_balancer_type": { + "id": 1, + "name": "lb11", + "description": "lb11", + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "max_assigned_certificates": 10, + "deprecated": "2016-01-30T23:50:00+00:00", + "prices": [ + { + "location": "fsn-1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } + ], + }, + "protection": {"delete": False}, + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "services": [ + { + "protocol": "https", + "listen_port": 443, + "destination_port": 80, + "proxyprotocol": False, + "http": { + "sticky_sessions": True, + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "certificates": [897], + "redirect_http": True, + "timeout_idle": 60, + }, + "health_check": { + "protocol": "http", + "port": 4711, + "interval": 15, + "timeout": 10, + "retries": 3, + "http": { + "domain": "example.com", + "path": "/", + "response": '{"status": "ok"}', + "status_codes": [200], + "tls": False, + }, + }, + } + ], + "targets": [ + { + "type": "server", + "server": {"id": 80}, + "health_status": [{"listen_port": 443, "status": "healthy"}], + "label_selector": None, + "use_private_ip": False, + } + ], + "algorithm": {"type": "round_robin"}, + }, + ] + } + + +@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 { + "action": { + "id": 13, + "command": "add_service", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_delete_service(): + return { + "action": { + "id": 13, + "command": "delete_service", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_add_target(): + return { + "action": { + "id": 13, + "command": "add_target", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_remove_target(): + return { + "action": { + "id": 13, + "command": "remove_target", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_update_service(): + return { + "action": { + "id": 13, + "command": "update_service", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_change_algorithm(): + return { + "action": { + "id": 13, + "command": "change_algorithm", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_change_reverse_dns_entry(): + return { + "action": { + "id": 13, + "command": "change_dns_ptr", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 42, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_change_protection(): + return { + "action": { + "id": 13, + "command": "change_protection", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_enable_public_interface(): + return { + "action": { + "id": 13, + "command": "enable_public_interface", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_disable_public_interface(): + return { + "action": { + "id": 13, + "command": "disable_public_interface", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_attach_load_balancer_to_network(): + return { + "action": { + "id": 13, + "command": "attach_to_network", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_detach_from_network(): + return { + "action": { + "id": 13, + "command": "detach_from_network", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_get_actions(): + return { + "actions": [ + { + "id": 13, + "command": "change_protection", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 14, "type": "load_balancer"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + ] + } diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py new file mode 100644 index 00000000..d9558f6d --- /dev/null +++ b/tests/unit/load_balancers/test_client.py @@ -0,0 +1,790 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.load_balancer_types import LoadBalancerType +from hcloud.load_balancers import ( + BoundLoadBalancer, + LoadBalancer, + LoadBalancerAlgorithm, + LoadBalancerHealthCheck, + LoadBalancersClient, + LoadBalancerService, + LoadBalancerServiceHttp, + LoadBalancerTarget, + LoadBalancerTargetIP, + LoadBalancerTargetLabelSelector, +) +from hcloud.locations import Location +from hcloud.networks import Network +from hcloud.servers import BoundServer, Server + +from ..conftest import BoundModelTestCase + + +class TestBoundLoadBalancer(BoundModelTestCase): + methods = [ + BoundLoadBalancer.update, + BoundLoadBalancer.delete, + BoundLoadBalancer.change_algorithm, + BoundLoadBalancer.change_dns_ptr, + BoundLoadBalancer.change_protection, + BoundLoadBalancer.change_type, + BoundLoadBalancer.add_service, + BoundLoadBalancer.update_service, + BoundLoadBalancer.delete_service, + BoundLoadBalancer.add_target, + BoundLoadBalancer.remove_target, + BoundLoadBalancer.attach_to_network, + BoundLoadBalancer.detach_from_network, + BoundLoadBalancer.disable_public_interface, + BoundLoadBalancer.enable_public_interface, + BoundLoadBalancer.get_metrics, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers + + @pytest.fixture() + def bound_model(self, resource_client: LoadBalancersClient): + return BoundLoadBalancer(resource_client, data=dict(id=1)) + + def test_init(self, response_load_balancer): + bound_load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), data=response_load_balancer["load_balancer"] + ) + + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + + def test_init_label_selector_nested_targets(self, response_load_balancer): + bound_load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), data=response_load_balancer["load_balancer"] + ) + + label_selector_target = bound_load_balancer.targets[1] + assert label_selector_target.type == "label_selector" + assert label_selector_target.label_selector.selector == "env=prod" + assert label_selector_target.use_private_ip is True + assert label_selector_target.targets is not None + assert len(label_selector_target.targets) == 1 + + nested = label_selector_target.targets[0] + assert nested.type == "server" + assert isinstance(nested.server, BoundServer) + assert nested.server.id == 105054278 + assert nested.use_private_ip is True + assert nested.health_status is not None + assert len(nested.health_status) == 2 + assert nested.health_status[0].listen_port == 443 + assert nested.health_status[0].status == "healthy" + assert nested.health_status[1].listen_port == 3000 + assert nested.health_status[1].status == "healthy" + + +class TestLoadBalancerslient: + @pytest.fixture() + def resource_client(self, client: Client): + return client.load_balancers + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_load_balancer, + ): + request_mock.return_value = response_load_balancer + + bound_load_balancer = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers/1", + ) + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + assert bound_load_balancer.outgoing_traffic == 123456 + assert bound_load_balancer.ingoing_traffic == 123456 + assert bound_load_balancer.included_traffic == 654321 + + @pytest.mark.parametrize( + "params", + [ + { + "name": "load_balancer1", + "label_selector": "label1", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, + ): + request_mock.return_value = response_simple_load_balancers + + result = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + bound_load_balancers = result.load_balancers + assert result.meta is not None + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + @pytest.mark.parametrize( + "params", [{"name": "loadbalancer1", "label_selector": "label1"}, {}] + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + params, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancers = resource_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert len(bound_load_balancers) == 2 + + bound_load_balancer1 = bound_load_balancers[0] + bound_load_balancer2 = bound_load_balancers[1] + + assert bound_load_balancer1._client is resource_client + assert bound_load_balancer1.id == 4711 + assert bound_load_balancer1.name == "Web Frontend" + + assert bound_load_balancer2._client is resource_client + assert bound_load_balancer2.id == 4712 + assert bound_load_balancer2.name == "Web Frontend2" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_simple_load_balancers, + ): + request_mock.return_value = response_simple_load_balancers + + bound_load_balancer = resource_client.get_by_name("Web Frontend") + + params = {"name": "Web Frontend"} + + request_mock.assert_called_with( + method="GET", + url="/load_balancers", + params=params, + ) + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 4711 + assert bound_load_balancer.name == "Web Frontend" + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + response_create_load_balancer, + ): + request_mock.return_value = response_create_load_balancer + + response = resource_client.create( + "my-balancer", + load_balancer_type=LoadBalancerType(name="lb11"), + location=Location(id=1), + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers", + json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + ) + + bound_load_balancer = response.load_balancer + + assert bound_load_balancer._client is resource_client + assert bound_load_balancer.id == 1 + assert bound_load_balancer.name == "my-balancer" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_name( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type( + load_balancer, LoadBalancerType(name="lb11") + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb11"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_change_type_with_load_balancer_type_id( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_type(load_balancer, LoadBalancerType(id=1)) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": 1}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_update_load_balancer, + ): + 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" + + @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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_add_service, + ): + request_mock.return_value = response_add_service + + service = LoadBalancerService( + listen_port=80, + protocol="http", + destination_port=8080, + proxyprotocol=False, + http=LoadBalancerServiceHttp( + cookie_name="HCLBSTICKY", + cookie_lifetime=300, + redirect_http=True, + sticky_sessions=True, + timeout_idle=60, + ), + ) + action = resource_client.add_service(load_balancer, service) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/add_service", + json={ + "protocol": "http", + "listen_port": 80, + "destination_port": 8080, + "proxyprotocol": False, + "http": { + "cookie_name": "HCLBSTICKY", + "cookie_lifetime": 300, + "redirect_http": True, + "sticky_sessions": True, + "timeout_idle": 60, + "certificates": [], + }, + }, + ) + + assert action.id == 13 + assert action.progress == 100 + assert action.command == "add_service" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_delete_service( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_delete_service, + ): + request_mock.return_value = response_delete_service + + service = LoadBalancerService(listen_port=12) + 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", + [ + ( + LoadBalancerTarget( + type="server", server=Server(id=1), use_private_ip=True + ), + {"server": {"id": 1}, "use_private_ip": True}, + ), + ( + LoadBalancerTarget(type="ip", ip=LoadBalancerTargetIP(ip="127.0.0.1")), + {"ip": {"ip": "127.0.0.1"}}, + ), + ( + LoadBalancerTarget( + type="label_selector", + label_selector=LoadBalancerTargetLabelSelector(selector="abc=def"), + ), + {"label_selector": {"selector": "abc=def"}}, + ), + ], + ) + def test_add_target( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_add_target, + target, + params, + ): + 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", + [ + ( + LoadBalancerTarget( + type="server", server=Server(id=1), use_private_ip=True + ), + {"server": {"id": 1}}, + ), + ( + LoadBalancerTarget(type="ip", ip=LoadBalancerTargetIP(ip="127.0.0.1")), + {"ip": {"ip": "127.0.0.1"}}, + ), + ( + LoadBalancerTarget( + type="label_selector", + label_selector=LoadBalancerTargetLabelSelector(selector="abc=def"), + ), + {"label_selector": {"selector": "abc=def"}}, + ), + ], + ) + def test_remove_target( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_remove_target, + target, + params, + ): + request_mock.return_value = response_remove_target + + action = resource_client.remove_target(load_balancer, target) + params.update({"type": target.type}) + + 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + 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 = 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": { + "protocol": "http", + "port": 13, + "interval": 1, + "timeout": 1, + "retries": 1, + }, + }, + ) + + 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_change_algorithm, + ): + request_mock.return_value = response_change_algorithm + + algorithm = LoadBalancerAlgorithm(type="round_robin") + 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_change_reverse_dns_entry, + ): + 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" + ) + + 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_enable_public_interface, + ): + 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, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_disable_public_interface, + ): + 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, + ): + request_mock.return_value = response_attach_load_balancer_to_network + + action = resource_client.attach_to_network(load_balancer, Network(id=1)) + + 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 == "attach_to_network" + + @pytest.mark.parametrize( + "load_balancer", + [LoadBalancer(id=1), BoundLoadBalancer(mock.MagicMock(), dict(id=1))], + ) + def test_detach_from_network( + self, + request_mock: mock.MagicMock, + resource_client: LoadBalancersClient, + load_balancer, + response_detach_from_network, + ): + request_mock.return_value = response_detach_from_network + + action = resource_client.detach_from_network(load_balancer, Network(id=1)) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/detach_from_network", + json={"network": 1}, + ) + + 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( + 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="lb21") + ) + + request_mock.assert_called_with( + method="POST", + url="/load_balancers/1/actions/change_type", + json={"load_balancer_type": "lb21"}, + ) + + assert action.id == 1 + assert action.progress == 0 diff --git a/tests/unit/load_balancers/test_domain.py b/tests/unit/load_balancers/test_domain.py new file mode 100644 index 00000000..47b081f2 --- /dev/null +++ b/tests/unit/load_balancers/test_domain.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import datetime +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 + + +@pytest.mark.parametrize( + "value", + [ + (LoadBalancer(id=1),), + (LoadBalancerService,), + (LoadBalancerServiceHttp(),), + (LoadBalancerHealthCheck(),), + (LoadBalancerHealthCheckHttp(),), + (LoadBalancerTarget(),), + (LoadBalancerTargetHealthStatus(),), + (LoadBalancerTargetLabelSelector(),), + (LoadBalancerTargetIP(),), + (LoadBalancerAlgorithm(),), + ( + PublicNetwork( + ipv4=IPv4Address(ip="127.0.0.1", dns_ptr="example.com"), + ipv6=IPv6Network("2001:0db8::0/64", dns_ptr="example.com"), + enabled=True, + ), + ), + (IPv4Address(ip="127.0.0.1", dns_ptr="example.com"),), + (IPv6Network("2001:0db8::0/64", dns_ptr="example.com"),), + (PrivateNet(network=object(), ip="127.0.0.1"),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestLoadBalancers: + def test_created_is_datetime(self): + lb = LoadBalancer(id=1, created="2016-01-30T23:50+00:00") + assert lb.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + load_balancer = LoadBalancer( + id=42, + private_net=[ + PrivateNet(network=network1, ip="127.0.0.1"), + PrivateNet(network=network2, ip="127.0.0.1"), + ], + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None + + load_balancer = BoundLoadBalancer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + {"network": 1, "ip": "127.0.0.1"}, + {"network": 2, "ip": "127.0.0.1"}, + ], + }, + ) + + assert load_balancer.private_net_for(network1).network.id == 1 + assert load_balancer.private_net_for(network3) is None diff --git a/tests/unit/locations/conftest.py b/tests/unit/locations/conftest.py index 6f2fb3c9..c7643ea4 100644 --- a/tests/unit/locations/conftest.py +++ b/tests/unit/locations/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -11,7 +13,8 @@ def location_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, + "network_zone": "eu-central", } } @@ -21,15 +24,14 @@ def two_locations_response(): return { "locations": [ { - "id": 1, "name": "fsn1", "description": "Falkenstein DC Park 1", "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 - + "longitude": 12.370071, + "network_zone": "eu-central", }, { "id": 2, @@ -38,8 +40,9 @@ def two_locations_response(): "country": "DE", "city": "Nuremberg", "latitude": 49.452102, - "longitude": 11.076665 - } + "longitude": 11.076665, + "network_zone": "eu-central", + }, ] } @@ -49,15 +52,14 @@ def one_locations_response(): return { "locations": [ { - "id": 1, "name": "fsn1", "description": "Falkenstein DC Park 1", "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 - + "longitude": 12.370071, + "network_zone": "eu-central", } ] } diff --git a/tests/unit/locations/test_client.py b/tests/unit/locations/test_client.py index 8213a8e8..61150c42 100644 --- a/tests/unit/locations/test_client.py +++ b/tests/unit/locations/test_client.py @@ -1,31 +1,59 @@ -import pytest # noqa: F401 -import mock # noqa: F401 +from __future__ import annotations + +from unittest import mock -from hcloud.locations.client import LocationsClient +import pytest # noqa: F401 +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 assert location.name == "fsn1" + assert location.network_zone == "eu-central" + + @pytest.mark.parametrize( + "params", [{"name": "fsn1", "page": 1, "per_page": 10}, {"name": ""}, {}] + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response - @pytest.mark.parametrize("params", [{'name': "fsn1", "page": 1, "per_page": 10}, {}]) - def test_get_list(self, locations_client, two_locations_response, params): - locations_client._client.request.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 @@ -35,19 +63,32 @@ def test_get_list(self, locations_client, two_locations_response, params): assert location1._client is locations_client assert location1.id == 1 assert location1.name == "fsn1" + assert location1.network_zone == "eu-central" assert location2._client is locations_client assert location2.id == 2 assert location2.name == "nbg1" + assert location2.network_zone == "eu-central" + + @pytest.mark.parametrize("params", [{"name": "fsn1"}, {}]) + def test_get_all( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + two_locations_response, + params, + ): + request_mock.return_value = two_locations_response - @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 locations = locations_client.get_all(**params) - params.update({'page': 1, 'per_page': 50}) + 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 @@ -57,19 +98,32 @@ def test_get_all(self, locations_client, two_locations_response, params): assert location1._client is locations_client assert location1.id == 1 assert location1.name == "fsn1" + assert location1.network_zone == "eu-central" assert location2._client is locations_client assert location2.id == 2 assert location2.name == "nbg1" + assert location2.network_zone == "eu-central" + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + locations_client: LocationsClient, + one_locations_response, + ): + request_mock.return_value = one_locations_response - def test_get_by_name(self, locations_client, one_locations_response): - locations_client._client.request.return_value = one_locations_response location = locations_client.get_by_name("fsn1") - params = {'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 assert location.id == 1 assert location.name == "fsn1" + assert location.network_zone == "eu-central" 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/integration/locations/__init__.py b/tests/unit/networks/__init__.py similarity index 100% rename from tests/integration/locations/__init__.py rename to tests/unit/networks/__init__.py diff --git a/tests/unit/networks/conftest.py b/tests/unit/networks/conftest.py new file mode 100644 index 00000000..69287d21 --- /dev/null +++ b/tests/unit/networks/conftest.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def network_response(): + return { + "network": { + "id": 1, + "name": "mynet", + "created": "2016-01-30T23:50:11+00:00", + "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", + }, + { + "type": "vswitch", + "ip_range": "10.0.3.0/24", + "network_zone": "eu-central", + "gateway": "10.0.3.1", + }, + ], + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, + "servers": [42], + "protection": {"delete": False}, + "labels": {}, + } + } + + +@pytest.fixture() +def two_networks_response(): + return { + "networks": [ + { + "id": 1, + "name": "mynet", + "created": "2016-01-30T23:50:11+00:00", + "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", + }, + { + "type": "vswitch", + "ip_range": "10.0.3.0/24", + "network_zone": "eu-central", + "gateway": "10.0.3.1", + }, + ], + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, + "servers": [42], + "protection": {"delete": False}, + "labels": {}, + }, + { + "id": 2, + "name": "myanothernet", + "created": "2016-01-30T23:50:11+00:00", + "ip_range": "12.0.0.0/8", + "subnets": [ + { + "type": "cloud", + "ip_range": "12.0.1.0/24", + "network_zone": "eu-central", + "gateway": "12.0.0.1", + } + ], + "routes": [{"destination": "12.100.1.0/24", "gateway": "12.0.1.1"}], + "expose_routes_to_vswitch": False, + "servers": [45], + "protection": {"delete": False}, + "labels": {}, + }, + ] + } + + +@pytest.fixture() +def one_network_response(): + return { + "networks": [ + { + "id": 1, + "name": "mynet", + "created": "2016-01-30T23:50:11+00:00", + "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", + }, + { + "type": "vswitch", + "ip_range": "10.0.3.0/24", + "network_zone": "eu-central", + "gateway": "10.0.3.1", + }, + ], + "routes": [{"destination": "10.100.1.0/24", "gateway": "10.0.1.1"}], + "expose_routes_to_vswitch": False, + "servers": [42], + "protection": {"delete": False}, + "labels": {}, + } + ] + } + + +@pytest.fixture() +def network_create_response(): + 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": 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": {}, + "created": "2016-01-30T23:50:00+00:00", + } + } + + +@pytest.fixture() +def response_update_network(): + return { + "network": { + "id": 4711, + "name": "new-name", + "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": {}, + "created": "2016-01-30T23:50:00+00:00", + } + } + + +@pytest.fixture() +def response_get_actions(): + return { + "actions": [ + { + "id": 13, + "command": "add_subnet", + "status": "success", + "progress": 100, + "started": "2016-01-30T23:55:00+00:00", + "finished": "2016-01-30T23:56:00+00:00", + "resources": [{"id": 4711, "type": "network"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + ] + } diff --git a/tests/unit/networks/test_client.py b/tests/unit/networks/test_client.py new file mode 100644 index 00000000..3f162b46 --- /dev/null +++ b/tests/unit/networks/test_client.py @@ -0,0 +1,611 @@ +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +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 + + @pytest.fixture() + def bound_model(self, resource_client: NetworksClient): + return BoundNetwork(resource_client, data=dict(id=14)) + + def test_init(self, network_response): + bound_network = BoundNetwork( + client=mock.MagicMock(), data=network_response["network"] + ) + + assert bound_network.id == 1 + assert bound_network.created == isoparse("2016-01-30T23:50:11+00:00") + assert bound_network.name == "mynet" + assert bound_network.ip_range == "10.0.0.0/16" + assert bound_network.protection["delete"] is False + + assert len(bound_network.servers) == 1 + assert isinstance(bound_network.servers[0], BoundServer) + assert bound_network.servers[0].id == 42 + assert bound_network.servers[0].complete is False + + assert len(bound_network.subnets) == 2 + assert isinstance(bound_network.subnets[0], NetworkSubnet) + assert bound_network.subnets[0].type == NetworkSubnet.TYPE_CLOUD + assert bound_network.subnets[0].ip_range == "10.0.1.0/24" + assert bound_network.subnets[0].network_zone == "eu-central" + assert bound_network.subnets[0].gateway == "10.0.0.1" + + assert len(bound_network.routes) == 1 + assert isinstance(bound_network.routes[0], NetworkRoute) + assert bound_network.routes[0].destination == "10.100.1.0/24" + assert bound_network.routes[0].gateway == "10.0.1.1" + + +class TestNetworksClient: + @pytest.fixture() + def networks_client(self, client: Client): + return NetworksClient(client) + + @pytest.fixture() + def network_subnet(self): + return NetworkSubnet( + type=NetworkSubnet.TYPE_CLOUD, + ip_range="10.0.1.0/24", + network_zone="eu-central", + ) + + @pytest.fixture() + def network_vswitch_subnet(self): + return NetworkSubnet( + type=NetworkSubnet.TYPE_VSWITCH, + ip_range="10.0.1.0/24", + network_zone="eu-central", + vswitch_id=123, + ) + + @pytest.fixture() + def network_route(self): + return NetworkRoute(destination="10.100.1.0/24", gateway="10.0.1.1") + + 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) + + request_mock.assert_called_with( + method="GET", + url="/networks/1", + ) + assert bound_network._client is networks_client + assert bound_network.id == 1 + assert bound_network.name == "mynet" + + @pytest.mark.parametrize( + "params", + [{"label_selector": "label1", "page": 1, "per_page": 10}, {"name": ""}, {}], + ) + 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) + + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, + ) + + bound_networks = result.networks + assert result.meta is not None + + assert len(bound_networks) == 2 + + bound_network1 = bound_networks[0] + bound_network2 = bound_networks[1] + + assert bound_network1._client is networks_client + assert bound_network1.id == 1 + assert bound_network1.name == "mynet" + + assert bound_network2._client is networks_client + assert bound_network2.id == 2 + assert bound_network2.name == "myanothernet" + + @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) + 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}) + + request_mock.assert_called_with( + method="GET", + url="/networks", + params=params, + ) + + assert len(bound_networks) == 2 + + bound_network1 = bound_networks[0] + bound_network2 = bound_networks[1] + + assert bound_network1._client is networks_client + assert bound_network1.id == 1 + assert bound_network1.name == "mynet" + + assert bound_network2._client is networks_client + assert bound_network2.id == 2 + assert bound_network2.name == "myanothernet" + + 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"} + + 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, + 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") + + 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, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, + ): + request_mock.return_value = network_create_response + + networks_client.create( + name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] + ) + + request_mock.assert_called_with( + method="POST", + url="/networks", + json={ + "name": "mynet", + "ip_range": "10.0.0.0/8", + "subnets": [ + { + "type": NetworkSubnet.TYPE_CLOUD, + "ip_range": "10.0.1.0/24", + "network_zone": "eu-central", + } + ], + }, + ) + + def test_create_with_subnet_vswitch( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_create_response, + ): + request_mock.return_value = network_create_response + + network_subnet.type = NetworkSubnet.TYPE_VSWITCH + network_subnet.vswitch_id = 1000 + networks_client.create( + name="mynet", ip_range="10.0.0.0/8", subnets=[network_subnet] + ) + + request_mock.assert_called_with( + method="POST", + url="/networks", + json={ + "name": "mynet", + "ip_range": "10.0.0.0/8", + "subnets": [ + { + "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, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network_subnet, + network_route, + network_create_response, + ): + request_mock.return_value = network_create_response + + networks_client.create( + name="mynet", + ip_range="10.0.0.0/8", + subnets=[network_subnet], + routes=[network_route], + ) + + request_mock.assert_called_with( + method="POST", + url="/networks", + json={ + "name": "mynet", + "ip_range": "10.0.0.0/8", + "subnets": [ + { + "type": NetworkSubnet.TYPE_CLOUD, + "ip_range": "10.0.1.0/24", + "network_zone": "eu-central", + } + ], + "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_update( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + response_update_network, + ): + request_mock.return_value = response_update_network + + network = networks_client.update( + network, name="new-name", expose_routes_to_vswitch=True + ) + + 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, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + ): + request_mock.return_value = action_response + + action = networks_client.change_protection(network, True) + + request_mock.assert_called_with( + method="POST", + url="/networks/1/actions/change_protection", + json={"delete": True}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + 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) + + request_mock.assert_called_with( + method="DELETE", + url="/networks/1", + ) + + assert delete_success is True + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + 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) + + 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", + "network_zone": "eu-central", + }, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_add_subnet_vswitch( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_vswitch_subnet, + ): + request_mock.return_value = action_response + + action = networks_client.add_subnet(network, network_vswitch_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", + "network_zone": "eu-central", + "vswitch_id": 123, + }, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_delete_subnet( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_subnet, + ): + request_mock.return_value = action_response + + action = networks_client.delete_subnet(network, network_subnet) + + request_mock.assert_called_with( + method="POST", + url="/networks/1/actions/delete_subnet", + json={"ip_range": "10.0.1.0/24"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + 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) + + 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"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + def test_delete_route( + self, + request_mock: mock.MagicMock, + networks_client: NetworksClient, + network, + action_response, + network_route, + ): + request_mock.return_value = action_response + + action = networks_client.delete_route(network, network_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"}, + ) + + assert action.id == 1 + assert action.progress == 0 + + @pytest.mark.parametrize( + "network", [Network(id=1), BoundNetwork(mock.MagicMock(), dict(id=1))] + ) + 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") + + request_mock.assert_called_with( + method="POST", + url="/networks/1/actions/change_ip_range", + json={"ip_range": "10.0.0.0/12"}, + ) + + assert action.id == 1 + assert action.progress == 0 diff --git a/tests/unit/networks/test_domain.py b/tests/unit/networks/test_domain.py new file mode 100644 index 00000000..dc3d3ba7 --- /dev/null +++ b/tests/unit/networks/test_domain.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import datetime +from datetime import timezone + +import pytest + +from hcloud.networks import Network, NetworkRoute, NetworkSubnet + + +@pytest.mark.parametrize( + "value", + [ + (Network(id=1),), + (NetworkSubnet(ip_range="10.0.1.0/24"),), + (NetworkRoute(destination="10.0.1.2", gateway="10.0.1.1"),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestNetwork: + 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=timezone.utc + ) diff --git a/tests/integration/server_types/__init__.py b/tests/unit/placement_groups/__init__.py similarity index 100% rename from tests/integration/server_types/__init__.py rename to tests/unit/placement_groups/__init__.py diff --git a/tests/unit/placement_groups/conftest.py b/tests/unit/placement_groups/conftest.py new file mode 100644 index 00000000..37f532a4 --- /dev/null +++ b/tests/unit/placement_groups/conftest.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def response_create_placement_group(): + return { + "placement_group": { + "created": "2019-01-08T12:10:00+00:00", + "id": 897, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [], + "type": "spread", + } + } + + +@pytest.fixture() +def one_placement_group_response(): + return { + "placement_groups": [ + { + "created": "2019-01-08T12:10:00+00:00", + "id": 897, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [4711, 4712], + "type": "spread", + } + ] + } + + +@pytest.fixture() +def two_placement_groups_response(): + return { + "placement_groups": [ + { + "created": "2019-01-08T12:10:00+00:00", + "id": 897, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [4711, 4712], + "type": "spread", + }, + { + "created": "2019-01-08T12:10:00+00:00", + "id": 898, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [4713, 4714, 4715], + "type": "spread", + }, + ] + } + + +@pytest.fixture() +def placement_group_response(): + return { + "placement_group": { + "created": "2019-01-08T12:10:00+00:00", + "id": 897, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [4711, 4712], + "type": "spread", + } + } diff --git a/tests/unit/placement_groups/test_client.py b/tests/unit/placement_groups/test_client.py new file mode 100644 index 00000000..fc8cada8 --- /dev/null +++ b/tests/unit/placement_groups/test_client.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.placement_groups import ( + BoundPlacementGroup, + PlacementGroupsClient, +) + +from ..conftest import BoundModelTestCase + + +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"] + assert placement_group.servers == expected["servers"] + assert placement_group.type == expected["type"] + + +class TestBoundPlacementGroup(BoundModelTestCase): + methods = [ + BoundPlacementGroup.update, + BoundPlacementGroup.delete, + ] + + @pytest.fixture() + def resource_client(self, client: Client): + return client.placement_groups + + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) + + def test_init(self, placement_group_response): + bound_placement_group = BoundPlacementGroup( + client=mock.MagicMock(), data=placement_group_response["placement_group"] + ) + + check_variables( + bound_placement_group, placement_group_response["placement_group"] + ) + + +class TestPlacementGroupsClient: + @pytest.fixture() + def resource_client(self, client: Client): + return client.placement_groups + + @pytest.fixture() + def bound_model(self, resource_client: PlacementGroupsClient): + return BoundPlacementGroup(resource_client, data=dict(id=897)) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + placement_group_response, + ): + request_mock.return_value = placement_group_response + + placement_group = resource_client.get_by_id( + placement_group_response["placement_group"]["id"] + ) + + request_mock.assert_called_with( + method="GET", + url="/placement_groups/897", + ) + + assert placement_group._client is resource_client + + check_variables(placement_group, placement_group_response["placement_group"]) + + @pytest.mark.parametrize( + "params", + [ + { + "name": "my Placement Group", + "sort": "id", + "label_selector": "key==value", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + two_placement_groups_response, + 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 not None + + assert len(placement_groups) == len( + two_placement_groups_response["placement_groups"] + ) + + for placement_group, expected in zip( + placement_groups, two_placement_groups_response["placement_groups"] + ): + assert placement_group._client is resource_client + + check_variables(placement_group, expected) + + @pytest.mark.parametrize( + "params", + [ + { + "name": "Corporate Intranet Protection", + "sort": "id", + "label_selector": "key==value", + }, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: PlacementGroupsClient, + two_placement_groups_response, + params, + ): + request_mock.return_value = two_placement_groups_response + + placement_groups = resource_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/placement_groups", + params=params, + ) + + assert len(placement_groups) == len( + two_placement_groups_response["placement_groups"] + ) + + for placement_group, expected in zip( + placement_groups, two_placement_groups_response["placement_groups"] + ): + assert placement_group._client is resource_client + + check_variables(placement_group, expected) + + 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"]} + + 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, + 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"], + ) + + json = { + "name": response_create_placement_group["placement_group"]["name"], + "labels": response_create_placement_group["placement_group"]["labels"], + "type": response_create_placement_group["placement_group"]["type"], + } + + request_mock.assert_called_with( + method="POST", + url="/placement_groups", + json=json, + ) + + bound_placement_group = response.placement_group + + 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 new file mode 100644 index 00000000..0d25b54f --- /dev/null +++ b/tests/unit/placement_groups/test_domain.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import datetime +from datetime import timezone + +import pytest + +from hcloud.placement_groups import PlacementGroup + + +@pytest.mark.parametrize( + "value", + [ + (PlacementGroup(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestPlacementGroup: + def test_created_is_datetime(self): + placement_group = PlacementGroup(id=1, created="2016-01-30T23:50+00:00") + assert placement_group.created == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) diff --git a/tests/integration/servers/__init__.py b/tests/unit/primary_ips/__init__.py similarity index 100% rename from tests/integration/servers/__init__.py rename to tests/unit/primary_ips/__init__.py diff --git a/tests/unit/primary_ips/conftest.py b/tests/unit/primary_ips/conftest.py new file mode 100644 index 00000000..3d84d871 --- /dev/null +++ b/tests/unit/primary_ips/conftest.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def primary_ip1(): + return { + "id": 42, + "name": "primary-ip1", + "type": "ipv4", + "ip": "131.232.99.1", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "location": { + "id": 1, + "name": "fsn1", + }, + "dns_ptr": [ + {"dns_ptr": "server.example.com", "ip": "131.232.99.1"}, + ], + "labels": {"key": "value"}, + "protection": {"delete": False}, + "created": "2016-01-30T23:55:00Z", + } + + +@pytest.fixture() +def primary_ip2(): + return { + "id": 52, + "name": "primary-ip2", + "type": "ipv4", + "ip": "131.232.99.2", + "assignee_id": None, + "assignee_type": "server", + "auto_delete": True, + "blocked": False, + "location": { + "id": 1, + "name": "fsn1", + }, + "dns_ptr": [ + {"dns_ptr": "server.example.com", "ip": "131.232.99.1"}, + ], + "labels": {"key": "value"}, + "protection": {"delete": False}, + "created": "2016-01-30T23:55:00Z", + } diff --git a/tests/unit/primary_ips/test_client.py b/tests/unit/primary_ips/test_client.py new file mode 100644 index 00000000..f8df0225 --- /dev/null +++ b/tests/unit/primary_ips/test_client.py @@ -0,0 +1,401 @@ +from __future__ import annotations + +from unittest import mock + +import pytest + +from hcloud import Client +from hcloud.locations import BoundLocation, Location +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP, PrimaryIPsClient + +from ..conftest import BoundModelTestCase, assert_bound_action1 + + +def assert_bound_primary_ip1(o: BoundPrimaryIP, client: PrimaryIPsClient): + assert isinstance(o, BoundPrimaryIP) + assert o._client is client + assert o.id == 42 + assert o.name == "primary-ip1" + + +def assert_bound_primary_ip2(o: BoundPrimaryIP, client: PrimaryIPsClient): + assert isinstance(o, BoundPrimaryIP) + assert o._client is client + assert o.id == 52 + assert o.name == "primary-ip2" + + +class TestBoundPrimaryIP(BoundModelTestCase): + 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_ip1): + o = BoundPrimaryIP(client=mock.MagicMock(), data=primary_ip1) + + assert o.id == 42 + assert o.name == "primary-ip1" + assert o.ip == "131.232.99.1" + assert o.type == "ipv4" + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert o.blocked is False + + assert o.assignee_id == 17 + assert o.assignee_type == "server" + + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" + + +class TestPrimaryIPsClient: + @pytest.fixture() + def resource_client(self, client: Client): + return PrimaryIPsClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = {"primary_ip": primary_ip1} + + result = resource_client.get_by_id(1) + + request_mock.assert_called_with( + method="GET", + url="/primary_ips/1", + ) + assert_bound_primary_ip1(result, resource_client) + + def test_get_by_name( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = {"primary_ips": [primary_ip1]} + + result = resource_client.get_by_name("primary-ip1") + + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params={"name": "primary-ip1"}, + ) + assert_bound_primary_ip1(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"name": "primary-ip1"}, + {"label_selector": "key=value"}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + primary_ip2, + params, + ): + request_mock.return_value = {"primary_ips": [primary_ip1, primary_ip2]} + + result = resource_client.get_all(**params) + + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/primary_ips", + params=params, + ) + + assert len(result) == 2 + assert_bound_primary_ip1(result[0], resource_client) + assert_bound_primary_ip2(result[1], resource_client) + + def test_create_with_location( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": None, + } + + result = resource_client.create( + type="ipv4", + name="primary-ip1", + location=Location(name="fsn1"), + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "primary-ip1", + "type": "ipv4", + "location": "fsn1", + "auto_delete": False, + }, + ) + assert_bound_primary_ip1(result.primary_ip, resource_client) + assert result.action is None + + def test_create_with_assignee_id( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + action1_running, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": action1_running, + } + + result = resource_client.create( + type="ipv4", + name="primary-ip1", + assignee_id=17, + assignee_type="server", + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "primary-ip1", + "type": "ipv4", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": False, + }, + ) + + assert_bound_primary_ip1(result.primary_ip, resource_client) + assert_bound_action1(result.action, resource_client._parent.actions) + + def test_create_with_assignee_type_deprecation( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip1, + action1_running, + ): + request_mock.return_value = { + "primary_ip": primary_ip1, + "action": action1_running, + } + + with pytest.deprecated_call(): + resource_client.create( + type="ipv4", + name="primary-ip1", + assignee_id=17, + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips", + json={ + "name": "primary-ip1", + "type": "ipv4", + "assignee_id": 17, + "assignee_type": "server", + "auto_delete": False, + }, + ) + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + primary_ip1, + ): + request_mock.return_value = {"primary_ip": primary_ip1} + + result = resource_client.update( + primary_ip, + name="changed", + auto_delete=True, + ) + + request_mock.assert_called_with( + method="PUT", + url="/primary_ips/42", + json={ + "name": "changed", + "auto_delete": True, + }, + ) + assert_bound_primary_ip1(result, resource_client) + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + ): + request_mock.return_value = None + + result = resource_client.delete(primary_ip) + + request_mock.assert_called_with( + method="DELETE", + url="/primary_ips/42", + ) + + assert result is True + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + result = resource_client.change_protection( + primary_ip, + delete=True, + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/change_protection", + json={ + "delete": True, + }, + ) + assert_bound_action1(result, resource_client._parent.actions) + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_assign( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + result = resource_client.assign( + primary_ip, + assignee_id=17, + assignee_type="server", + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/assign", + json={ + "assignee_id": 17, + "assignee_type": "server", + }, + ) + assert_bound_action1(result, resource_client._parent.actions) + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_unassign( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + result = resource_client.unassign(primary_ip) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/unassign", + ) + assert_bound_action1(result, resource_client._parent.actions) + + @pytest.mark.parametrize( + "primary_ip", + [ + PrimaryIP(id=42), + BoundPrimaryIP(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_dns_ptr( + self, + request_mock: mock.MagicMock, + resource_client: PrimaryIPsClient, + primary_ip, + action_response, + ): + request_mock.return_value = action_response + + result = resource_client.change_dns_ptr( + primary_ip, + ip="1.2.3.4", + dns_ptr="server02.example.com", + ) + + request_mock.assert_called_with( + method="POST", + url="/primary_ips/42/actions/change_dns_ptr", + json={ + "ip": "1.2.3.4", + "dns_ptr": "server02.example.com", + }, + ) + assert_bound_action1(result, resource_client._parent.actions) diff --git a/tests/unit/primary_ips/test_domain.py b/tests/unit/primary_ips/test_domain.py 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 61e0d29c..095ab7f4 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, @@ -16,16 +19,42 @@ def server_type_response(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" + "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, + "available": True, + "recommended": False, + }, + { + "id": 2, + "name": "fsn1", + "deprecation": { + "announced": "2023-06-01T00:00:00Z", + "unavailable_after": "2023-09-01T00:00:00Z", + }, + "available": True, + "recommended": True, + }, + ], } } @@ -46,16 +75,23 @@ def two_server_types_response(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" + "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, @@ -69,28 +105,32 @@ def two_server_types_response(): "location": "fsn1", "price_hourly": { "net": "0.0080000000", - "gross": "0.0095200000000000" + "gross": "0.0095200000000000", }, "price_monthly": { "net": "4.9000000000", - "gross": "5.8310000000000000" - } + "gross": "5.8310000000000000", + }, }, { "location": "nbg1", "price_hourly": { "net": "0.0080000000", - "gross": "0.0095200000000000" + "gross": "0.0095200000000000", }, "price_monthly": { "net": "4.9000000000", - "gross": "5.8310000000000000" - } - } + "gross": "5.8310000000000000", + }, + }, ], "storage_type": "local", - "cpu_type": "shared" - } + "cpu_type": "shared", + "architecture": "x86", + "included_traffic": 21990232555520, + "deprecated": False, + "deprecation": None, + }, ] } @@ -111,16 +151,23 @@ def one_server_types_response(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" + "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 95b7a2f4..b8d266ff 100644 --- a/tests/unit/server_types/test_client.py +++ b/tests/unit/server_types/test_client.py @@ -1,32 +1,108 @@ -import pytest -import mock +from __future__ import annotations +from datetime import datetime, timezone +from unittest import mock -from hcloud.server_types.client import ServerTypesClient +import pytest +from hcloud import Client +from hcloud.server_types import BoundServerType, ServerTypesClient -class TestServerTypesClient(object): +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[0].available is True + assert o.locations[0].recommended is False + assert o.locations[1].location.id == 2 + assert o.locations[1].location.name == "fsn1" + assert ( + o.locations[1].deprecation.announced.isoformat() + == "2023-06-01T00:00:00+00:00" + ) + assert ( + o.locations[1].deprecation.unavailable_after.isoformat() + == "2023-09-01T00:00:00+00:00" + ) + assert o.locations[1].available is True + assert o.locations[1].recommended is True + + with pytest.deprecated_call(): + assert o.deprecated is True + assert o.deprecation is not None + assert o.deprecation.announced == datetime(2023, 6, 1, tzinfo=timezone.utc) + assert o.deprecation.unavailable_after == datetime( + 2023, 9, 1, tzinfo=timezone.utc + ) + assert o.included_traffic == 21990232555520 + + +class TestServerTypesClient: @pytest.fixture() - def server_types_client(self): - return ServerTypesClient(client=mock.MagicMock()) + def server_types_client(self, client: Client): + return ServerTypesClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + server_types_client: ServerTypesClient, + server_type_response, + ): + request_mock.return_value = server_type_response - def test_get_by_id(self, server_types_client, server_type_response): - server_types_client._client.request.return_value = server_type_response server_type = server_types_client.get_by_id(1) - server_types_client._client.request.assert_called_with(url="/server_types/1", method="GET") + + request_mock.assert_called_with( + method="GET", + url="/server_types/1", + ) assert server_type._client is server_types_client assert server_type.id == 1 assert server_type.name == "cx11" - @pytest.mark.parametrize("params", [{'name': "cx11", 'page': 1, 'per_page': 10}]) - def test_get_list(self, server_types_client, two_server_types_response, params): - server_types_client._client.request.return_value = two_server_types_response + @pytest.mark.parametrize( + "params", [{"name": "cx11", "page": 1, "per_page": 10}, {"name": ""}, {}] + ) + 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 @@ -41,14 +117,25 @@ def test_get_list(self, server_types_client, two_server_types_response, params): assert server_types2.id == 2 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 + @pytest.mark.parametrize("params", [{"name": "cx11"}]) + 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}) + 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 @@ -63,13 +150,23 @@ 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"} + 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 assert server_type.id == 1 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 898f07fe..2ccf5d64 100644 --- a/tests/unit/servers/conftest.py +++ b/tests/unit/servers/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -12,23 +14,27 @@ def response_simple_server(): "public_net": { "ipv4": { "ip": "1.2.3.4", + "id": 1, "blocked": False, - "dns_ptr": "server01.example.com" + "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ] + "id": 2, + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, - "floating_ips": [ - 478 - ] + "floating_ips": [478], + "firewalls": [{"id": 38, "status": "applied"}], }, + "private_net": [ + { + "network": 4711, + "ip": "10.1.1.5", + "alias_ips": ["10.1.1.8"], + "mac_address": "86:00:ff:2a:7d:e1", + } + ], "server_type": { "id": 1, "name": "cx11", @@ -41,71 +47,34 @@ def response_simple_server(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "cpu_type": "shared", }, "image": { "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", "rapid_deploy": False, - "protection": { - "delete": False, - "rebuild": False - }, + "protection": {"delete": False, "rebuild": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, }, "iso": None, "rescue_enabled": False, @@ -114,9 +83,10 @@ def response_simple_server(): "outgoing_traffic": 123456, "ingoing_traffic": 123456, "included_traffic": 654321, + "primary_disk_size": 20, "protection": {}, "labels": {}, - "volumes": [] + "volumes": [], } } @@ -129,25 +99,24 @@ def response_create_simple_server(): "name": "my-server", "status": "running", "created": "2016-01-30T23:50+00:00", + "primary_disk_size": 20, "public_net": { "ipv4": { "ip": "1.2.3.4", "blocked": False, - "dns_ptr": "server01.example.com" + "id": 1, + "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ] + "id": 2, + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, - "floating_ips": [ - ] + "floating_ips": [], + "firewalls": [{"id": 38, "status": "applied"}], }, + "private_net": [], "server_type": { "id": 1, "name": "cx11", @@ -160,75 +129,36 @@ def response_create_simple_server(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "cpu_type": "shared", }, "image": { "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", "rapid_deploy": False, - "protection": { - "delete": False, - "rebuild": False - }, + "protection": {"delete": False, "rebuild": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} - }, - "iso": { - "id": 4711, + "labels": {}, }, + "iso": {"id": 4711}, "rescue_enabled": False, "locked": False, "backup_window": "22-02", @@ -237,7 +167,7 @@ def response_create_simple_server(): "included_traffic": 654321, "protection": {}, "labels": {}, - "volumes": [] + "volumes": [], }, "action": { "id": 1, @@ -246,16 +176,8 @@ def response_create_simple_server(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, }, "next_actions": [ { @@ -265,19 +187,11 @@ def response_create_simple_server(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, } ], - "root_password": "YItygq1v3GYjjMomLaKc" + "root_password": "YItygq1v3GYjjMomLaKc", } @@ -293,22 +207,19 @@ def response_update_server(): "ipv4": { "ip": "1.2.3.4", "blocked": False, - "dns_ptr": "server01.example.com" + "id": 1, + "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ] + "id": 2, + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, - "floating_ips": [ - 478 - ] + "floating_ips": [478], + "firewalls": [], }, + "private_net": [], "server_type": { "id": 1, "name": "cx11", @@ -321,77 +232,41 @@ def response_update_server(): "location": "fsn1", "price_hourly": { "net": "1.0000000000", - "gross": "1.1900000000000000" + "gross": "1.1900000000000000", }, "price_monthly": { "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "gross": "1.1900000000000000", + }, } ], "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "cpu_type": "shared", }, "image": { "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, }, "iso": { "id": 4711, "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", - "deprecated": "2018-02-28T00:00:00+00:00" + "deprecated": "2018-02-28T00:00:00+00:00", }, "rescue_enabled": False, "locked": False, @@ -399,246 +274,233 @@ def response_update_server(): "outgoing_traffic": 123456, "ingoing_traffic": 123456, "included_traffic": 654321, - "protection": { - "delete": False, - "rebuild": False - }, + "protection": {"delete": False, "rebuild": False}, "labels": {}, - "volumes": [] + "volumes": [], } } @pytest.fixture() -def response_simple_servers(): +def response_get_metrics(): return { - "servers": [{ - "id": 1, - "name": "my-server", - "status": "running", - "created": "2016-01-30T23:50+00:00", - "public_net": { - "ipv4": { - "ip": "1.2.3.4", - "blocked": False, - "dns_ptr": "server01.example.com" + "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"], + ] }, - "ipv6": { - "ip": "2001:db8::/64", - "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } + "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"], ] }, - "floating_ips": [ - 478 - ] }, - "server_type": { + } + } + + +@pytest.fixture() +def response_simple_servers(): + return { + "servers": [ + { "id": 1, - "name": "cx11", - "description": "CX11", - "cores": 1, - "memory": 1, - "disk": 25, - "prices": [ + "name": "my-server", + "status": "running", + "created": "2016-01-30T23:50+00:00", + "public_net": { + "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"} + ], + }, + "floating_ips": [478], + "firewalls": [], + }, + "private_net": [ { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "network": 4711, + "ip": "10.1.1.5", + "alias_ips": ["10.1.1.8"], + "mac_address": "86:00:ff:2a:7d:e1", } ], - "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { + "server_type": { "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 + "name": "cx11", + "description": "CX11", + "cores": 1, + "memory": 1, + "disk": 25, + "prices": [ + { + "location": "fsn1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } - }, - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" + "storage_type": "local", + "cpu_type": "shared", }, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "rapid_deploy": False, - "protection": { - "delete": False, - "rebuild": False + "image": { + "id": 4711, + "type": "snapshot", + "status": "available", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", + "image_size": 2.3, + "disk_size": 10, + "created": "2016-01-30T23:50+00:00", + "created_from": {"id": 1, "name": "Server"}, + "bound_to": None, + "os_flavor": "ubuntu", + "os_version": "16.04", + "rapid_deploy": False, + "protection": {"delete": False, "rebuild": False}, + "deprecated": "2018-02-28T00:00:00+00:00", + "labels": {}, }, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "iso": None, + "rescue_enabled": False, + "locked": False, + "backup_window": "22-02", + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "protection": {}, + "labels": {}, + "volumes": [], }, - "iso": None, - "rescue_enabled": False, - "locked": False, - "backup_window": "22-02", - "outgoing_traffic": 123456, - "ingoing_traffic": 123456, - "included_traffic": 654321, - "protection": {}, - "labels": {}, - "volumes": [] - }, { - "id": 2, - "name": "my-server2", - "status": "running", - "created": "2016-03-30T23:50+00:00", - "public_net": { - "ipv4": { - "ip": "1.2.3.4", - "blocked": False, - "dns_ptr": "server01.example.com" - }, - "ipv6": { - "ip": "2001:db8::/64", - "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ] + { + "id": 2, + "name": "my-server2", + "status": "running", + "created": "2016-03-30T23:50+00:00", + "public_net": { + "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"} + ], + }, + "floating_ips": [478], + "firewalls": [], }, - "floating_ips": [ - 478 - ] - }, - "server_type": { - "id": 1, - "name": "cx11", - "description": "CX11", - "cores": 1, - "memory": 1, - "disk": 25, - "prices": [ + "private_net": [ { - "location": "fsn1", - "price_hourly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - }, - "price_monthly": { - "net": "1.0000000000", - "gross": "1.1900000000000000" - } + "network": 4711, + "ip": "10.1.1.7", + "alias_ips": ["10.1.1.99"], + "mac_address": "86:00:ff:2a:7d:e1", } ], - "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { + "server_type": { "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 + "name": "cx11", + "description": "CX11", + "cores": 1, + "memory": 1, + "disk": 25, + "prices": [ + { + "location": "fsn1", + "price_hourly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + "price_monthly": { + "net": "1.0000000000", + "gross": "1.1900000000000000", + }, + } ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } - }, - "image": { - "id": 4711, - "type": "snapshot", - "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", - "image_size": 2.3, - "disk_size": 10, - "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" + "storage_type": "local", + "cpu_type": "shared", }, - "bound_to": None, - "os_flavor": "ubuntu", - "os_version": "16.04", - "rapid_deploy": False, - "protection": { - "delete": False, - "rebuild": False + "image": { + "id": 4711, + "type": "snapshot", + "status": "available", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", + "image_size": 2.3, + "disk_size": 10, + "created": "2016-01-30T23:50+00:00", + "created_from": {"id": 1, "name": "Server"}, + "bound_to": None, + "os_flavor": "ubuntu", + "os_version": "16.04", + "rapid_deploy": False, + "protection": {"delete": False, "rebuild": False}, + "deprecated": "2018-02-28T00:00:00+00:00", + "labels": {}, }, - "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "iso": None, + "rescue_enabled": False, + "locked": False, + "backup_window": "22-02", + "outgoing_traffic": 123456, + "ingoing_traffic": 123456, + "included_traffic": 654321, + "primary_disk_size": 20, + "protection": {}, + "labels": {}, + "volumes": [], }, - "iso": None, - "rescue_enabled": False, - "locked": False, - "backup_window": "22-02", - "outgoing_traffic": 123456, - "ingoing_traffic": 123456, - "included_traffic": 654321, - "protection": {}, - "labels": {}, - "volumes": [] - }] + ] } @@ -650,26 +512,31 @@ def response_full_server(): "name": "my-server", "status": "running", "created": "2016-01-30T23:50+00:00", + "primary_disk_size": 20, "public_net": { "ipv4": { "ip": "1.2.3.4", "blocked": False, - "dns_ptr": "server01.example.com" + "id": 1, + "dns_ptr": "server01.example.com", }, "ipv6": { "ip": "2001:db8::/64", "blocked": False, - "dns_ptr": [ - { - "ip": "2001:db8::1", - "dns_ptr": "server.example.com" - } - ] + "id": 2, + "dns_ptr": [{"ip": "2001:db8::1", "dns_ptr": "server.example.com"}], }, - "floating_ips": [ - 478 - ] + "floating_ips": [478], + "firewalls": [{"id": 38, "status": "applied"}], }, + "private_net": [ + { + "network": 4711, + "ip": "10.1.1.5", + "alias_ips": ["10.1.1.8"], + "mac_address": "86:00:ff:2a:7d:e1", + } + ], "server_type": { "id": 1, "name": "cx11", @@ -679,68 +546,40 @@ def response_full_server(): "disk": 25, "prices": [], "storage_type": "local", - "cpu_type": "shared" - }, - "datacenter": { - "id": 1, - "name": "fsn1-dc8", - "description": "Falkenstein 1 DC 8", - "location": { - "id": 1, - "name": "fsn1", - "description": "Falkenstein DC Park 1", - "country": "DE", - "city": "Falkenstein", - "latitude": 50.47612, - "longitude": 12.370071 - }, - "server_types": { - "supported": [ - 1, - 2, - 3 - ], - "available": [ - 1, - 2, - 3 - ], - "available_for_migration": [ - 1, - 2, - 3 - ] - } + "cpu_type": "shared", }, "image": { "id": 4711, "type": "snapshot", "status": "available", - "name": "ubuntu-16.04", - "description": "Ubuntu 16.04 Standard 64 bit", + "name": "ubuntu-20.04", + "description": "Ubuntu 20.04 Standard 64 bit", "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, }, "iso": { "id": 4711, "name": "FreeBSD-11.0-RELEASE-amd64-dvd1", "description": "FreeBSD 11.0 x64", "type": "public", - "deprecated": "2018-02-28T00:00:00+00:00" + "deprecated": "2018-02-28T00:00:00+00:00", + }, + "placement_group": { + "created": "2019-01-08T12:10:00+00:00", + "id": 897, + "labels": {"key": "value"}, + "name": "my Placement Group", + "servers": [4711, 4712], + "type": "spread", }, "rescue_enabled": False, "locked": False, @@ -750,7 +589,7 @@ def response_full_server(): "included_traffic": 654321, "protection": {}, "labels": {}, - "volumes": [1, 2] + "volumes": [1, 2], } } @@ -765,18 +604,10 @@ def response_server_reset_password(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, }, - "root_password": "YItygq1v3GYjjMomLaKc" + "root_password": "YItygq1v3GYjjMomLaKc", } @@ -790,18 +621,10 @@ def response_server_enable_rescue(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, }, - "root_password": "YItygq1v3GYjjMomLaKc" + "root_password": "YItygq1v3GYjjMomLaKc", } @@ -817,19 +640,14 @@ def response_server_create_image(): "image_size": 2.3, "disk_size": 10, "created": "2016-01-30T23:50+00:00", - "created_from": { - "id": 1, - "name": "Server" - }, + "created_from": {"id": 1, "name": "Server"}, "bound_to": None, "os_flavor": "ubuntu", "os_version": "16.04", "rapid_deploy": False, - "protection": { - "delete": False - }, + "protection": {"delete": False}, "deprecated": "2018-02-28T00:00:00+00:00", - "labels": {} + "labels": {}, }, "action": { "id": 1, @@ -838,17 +656,9 @@ def response_server_create_image(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, + }, } @@ -864,17 +674,9 @@ def response_server_request_console(): "progress": 0, "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" - } - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, + }, } @@ -889,16 +691,129 @@ def response_get_actions(): "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" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, } ] } + + +@pytest.fixture() +def response_attach_to_network(): + return { + "action": { + "id": 1, + "command": "attach_to_network", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": None, + "resources": [ + {"id": 42, "type": "server"}, + {"id": 4711, "type": "network"}, + ], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_detach_from_network(): + return { + "action": { + "id": 1, + "command": "detach_from_network", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": None, + "resources": [ + {"id": 42, "type": "server"}, + {"id": 4711, "type": "network"}, + ], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_change_alias_ips(): + return { + "action": { + "id": 1, + "command": "change_alias_ips", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": None, + "resources": [ + {"id": 42, "type": "server"}, + {"id": 4711, "type": "network"}, + ], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_apply_firewall(): + return { + "action": { + "id": 1, + "command": "apply_firewall", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": None, + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_remove_firewall(): + return { + "action": { + "id": 1, + "command": "remove_firewall", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": None, + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, + } + } + + +@pytest.fixture() +def response_add_to_placement_group(): + return { + "action": { + "command": "add_to_placement_group", + "error": {"code": "action_failed", "message": "Action failed"}, + "finished": None, + "id": 13, + "progress": 0, + "resources": [{"id": 42, "type": "server"}], + "started": "2016-01-30T23:50:00+00:00", + "status": "running", + } + } + + +@pytest.fixture() +def response_remove_from_placement_group(): + return { + "action": { + "command": "remove_from_placement_group", + "error": {"code": "action_failed", "message": "Action failed"}, + "finished": "2016-01-30T23:56:00+00:00", + "id": 13, + "progress": 100, + "resources": [{"id": 42, "type": "server"}], + "started": "2016-01-30T23:55:00+00:00", + "status": "success", + } + } diff --git a/tests/unit/servers/test_client.py b/tests/unit/servers/test_client.py index 890c17e2..3d3ab085 100644 --- a/tests/unit/servers/test_client.py +++ b/tests/unit/servers/test_client.py @@ -1,38 +1,81 @@ -import mock -import pytest +from __future__ import annotations -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, IPv4Address, IPv6Network -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 +import pytest +from hcloud import Client +from hcloud.actions import BoundAction +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, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServersClient, +) +from hcloud.volumes import BoundVolume, Volume + +from ..conftest import BoundModelTestCase + + +class TestBoundServer(BoundModelTestCase): + methods = [ + BoundServer.update, + BoundServer.delete, + BoundServer.add_to_placement_group, + BoundServer.remove_from_placement_group, + BoundServer.attach_iso, + BoundServer.detach_iso, + BoundServer.attach_to_network, + BoundServer.detach_from_network, + BoundServer.change_alias_ips, + BoundServer.change_dns_ptr, + BoundServer.change_protection, + BoundServer.change_type, + BoundServer.create_image, + BoundServer.disable_backup, + BoundServer.enable_backup, + BoundServer.disable_rescue, + BoundServer.enable_rescue, + BoundServer.get_metrics, + BoundServer.power_off, + BoundServer.power_on, + BoundServer.reboot, + BoundServer.rebuild, + BoundServer.shutdown, + BoundServer.reset, + BoundServer.request_console, + BoundServer.reset_password, + ] -class TestBoundServer(object): + @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'] + client=mock.MagicMock(), data=response_full_server["server"] ) assert bound_server.id == 42 assert bound_server.name == "my-server" + assert bound_server.primary_disk_size == 20 assert isinstance(bound_server.public_net, PublicNetwork) assert isinstance(bound_server.public_net.ipv4, IPv4Address) assert bound_server.public_net.ipv4.ip == "1.2.3.4" @@ -45,265 +88,88 @@ def test_bound_server_init(self, response_full_server): assert bound_server.public_net.ipv6.network == "2001:db8::" assert bound_server.public_net.ipv6.network_mask == "64" + assert isinstance(bound_server.public_net.firewalls, list) + assert isinstance(bound_server.public_net.firewalls[0], PublicNetworkFirewall) + firewall = bound_server.public_net.firewalls[0] + assert isinstance(firewall.firewall, BoundFirewall) + assert bound_server.public_net.ipv6.blocked is False + assert firewall.status == PublicNetworkFirewall.STATUS_APPLIED + assert isinstance(bound_server.public_net.floating_ips[0], BoundFloatingIP) assert bound_server.public_net.floating_ips[0].id == 478 assert bound_server.public_net.floating_ips[0].complete is False - assert isinstance(bound_server.datacenter, BoundDatacenter) - assert bound_server.datacenter._client == bound_server._client._client.datacenters - assert bound_server.datacenter.id == 1 - assert bound_server.datacenter.complete is True - assert isinstance(bound_server.server_type, BoundServerType) - assert bound_server.server_type._client == bound_server._client._client.server_types + assert ( + bound_server.server_type._client + == bound_server._client._parent.server_types + ) assert bound_server.server_type.id == 1 assert bound_server.server_type.complete is True assert len(bound_server.volumes) == 2 assert isinstance(bound_server.volumes[0], BoundVolume) - assert bound_server.volumes[0]._client == bound_server._client._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-16.04" + 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 - @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-16.04")) - hetzner_client.request.assert_called_with(url="/servers/14/actions/rebuild", method="POST", json={"image": "ubuntu-16.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" - + assert len(bound_server.private_net) == 1 + assert isinstance(bound_server.private_net[0], PrivateNet) + assert ( + bound_server.private_net[0].network._client + == bound_server._client._parent.networks + ) + assert bound_server.private_net[0].ip == "10.1.1.5" + assert bound_server.private_net[0].mac_address == "86:00:ff:2a:7d:e1" + assert len(bound_server.private_net[0].alias_ips) == 1 + assert bound_server.private_net[0].alias_ips[0] == "10.1.1.8" + + assert isinstance(bound_server.placement_group, BoundPlacementGroup) + assert ( + bound_server.placement_group._client + == bound_server._client._parent.placement_groups + ) + assert bound_server.placement_group.id == 897 + assert bound_server.placement_group.name == "my Placement Group" + assert bound_server.placement_group.complete is True -class TestServersClient(object): +class TestServersClient: @pytest.fixture() - def servers_client(self): - return ServersClient(client=mock.MagicMock()) + def servers_client(self, client: Client): + return ServersClient(client) + + def test_get_by_id( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_simple_server, + ): + request_mock.return_value = response_simple_server - 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 assert bound_server.name == "my-server" @@ -311,17 +177,30 @@ def test_get_by_id(self, servers_client, response_simple_server): @pytest.mark.parametrize( "params", [ - {'name': "server1", 'label_selector': "label1", 'page': 1, 'per_page': 10}, - {} - ] + {"name": "server1", "label_selector": "label1", "page": 1, "per_page": 10}, + {"name": ""}, + {}, + ], ) - 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 @@ -337,19 +216,26 @@ def test_get_list(self, servers_client, response_simple_servers, params): assert bound_server2.name == "my-server2" @pytest.mark.parametrize( - "params", - [ - {'name': "server1", 'label_selector': "label1"}, - {} - ] + "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}) + 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 @@ -364,96 +250,188 @@ 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"} + 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 assert bound_server.id == 1 assert bound_server.name == "my-server" - def test_create_with_datacenter(self, servers_client, response_create_simple_server): - servers_client._client.request.return_value = response_create_simple_server + 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(id=4711), - datacenter=Datacenter(id=1) + 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", - 'image': 4711, - 'datacenter': 1, - "start_after_create": True - } + "name": "my-server", + "server_type": "cx11", + "image": "ubuntu-20.04", + "location": "fsn1", + "start_after_create": True, + }, ) bound_server = response.server + bound_action = response.action assert bound_server._client is servers_client assert bound_server.id == 1 assert bound_server.name == "my-server" - 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_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", server_type=ServerType(name="cx11"), - image=Image(name="ubuntu-18.04"), - location=Location(name="fsn1") + image=Image(id=4711), + 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", - 'image': "ubuntu-18.04", - 'location': "fsn1", - "start_after_create": True - } + "name": "my-server", + "server_type": "cx11", + "image": 4711, + "volumes": [1, 2], + "start_after_create": False, + }, ) bound_server = response.server bound_action = response.action + next_actions = response.next_actions + root_password = response.root_password + + assert root_password == "YItygq1v3GYjjMomLaKc" assert bound_server._client is servers_client assert bound_server.id == 1 assert bound_server.name == "my-server" assert isinstance(bound_action, BoundAction) - assert bound_action._client == servers_client._client.actions + assert bound_action._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 - volumes = [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=2))] + assert next_actions[0].id == 13 + + 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", server_type=ServerType(name="cx11"), image=Image(id=4711), - volumes=volumes, - start_after_create=False + networks=networks, + start_after_create=False, ) - servers_client._client.request.assert_called_with( + + request_mock.assert_called_with( + method="POST", url="/servers", + json={ + "name": "my-server", + "server_type": "cx11", + "image": 4711, + "networks": [1, 2], + "start_after_create": False, + }, + ) + + bound_server = response.server + bound_action = response.action + next_actions = response.next_actions + root_password = response.root_password + + assert root_password == "YItygq1v3GYjjMomLaKc" + + assert bound_server._client is servers_client + assert bound_server.id == 1 + assert bound_server.name == "my-server" + + assert isinstance(bound_action, BoundAction) + assert bound_action._client == servers_client._parent.actions + assert bound_action.id == 1 + assert bound_action.command == "create_server" + + assert next_actions[0].id == 13 + + 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", + server_type=ServerType(name="cx11"), + image=Image(id=4711), + firewalls=firewalls, + start_after_create=False, + ) + + request_mock.assert_called_with( method="POST", + url="/servers", json={ - 'name': "my-server", - 'server_type': "cx11", - 'image': 4711, - 'volumes': [1, 2], - "start_after_create": False - } + "name": "my-server", + "server_type": "cx11", + "image": 4711, + "firewalls": [{"firewall": 1}, {"firewall": 2}], + "start_after_create": False, + }, ) bound_server = response.server @@ -468,226 +446,737 @@ 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 - @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={}) + def test_create_with_placement_group( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + response_create_simple_server, + ): + request_mock.return_value = response_create_simple_server + + placement_group = PlacementGroup(id=1) + response = servers_client.create( + "my-server", + server_type=ServerType(name="cx11"), + image=Image(id=4711), + start_after_create=False, + placement_group=placement_group, + ) - actions = result.actions - assert result.meta is None + request_mock.assert_called_with( + method="POST", + url="/servers", + json={ + "name": "my-server", + "server_type": "cx11", + "image": 4711, + "placement_group": 1, + "start_after_create": False, + }, + ) - assert len(actions) == 1 - assert isinstance(actions[0], BoundAction) + bound_server = response.server + bound_action = response.action + next_actions = response.next_actions + root_password = response.root_password - assert actions[0]._client == servers_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "start_server" + assert root_password == "YItygq1v3GYjjMomLaKc" + + assert bound_server._client is servers_client + assert bound_server.id == 1 + assert bound_server.name == "my-server" + + assert isinstance(bound_action, BoundAction) + assert bound_action._client == servers_client._parent.actions + assert bound_action.id == 1 + assert bound_action.command == "create_server" + + assert next_actions[0].id == 13 + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert server.name == "new-name" - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_reset_password(self, servers_client, server, response_server_reset_password): - servers_client._client.request.return_value = response_server_reset_password + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_reset_password( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + 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 assert response.action.progress == 0 assert response.root_password == "YItygq1v3GYjjMomLaKc" - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_change_type_with_server_type_name(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action - 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", method="POST", json={"server_type": "cx11", "upgrade_disk": True}) + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_change_type_with_server_type_name( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response + + action = servers_client.change_type( + server, ServerType(name="cx11"), upgrade_disk=True + ) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/change_type", + json={"server_type": "cx11", "upgrade_disk": True}, + ) assert action.id == 1 assert action.progress == 0 - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_change_type_with_server_type_id(self, servers_client, server, generic_action): - servers_client._client.request.return_value = generic_action + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_change_type_with_server_type_id( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + 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", method="POST", - json={"server_type": 1, "upgrade_disk": True}) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/change_type", + json={"server_type": 1, "upgrade_disk": True}, + ) assert action.id == 1 assert action.progress == 0 - @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): + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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() - @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 + request_mock.assert_not_called() + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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", method="POST", json={"type": "linux64", "ssh_keys": [2323]}) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/enable_rescue", + json={"type": "linux64", "ssh_keys": [2323]}, + ) assert response.action.id == 1 assert response.action.progress == 0 assert response.root_password == "YItygq1v3GYjjMomLaKc" - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 - response = servers_client.create_image(server, description="my image", type="snapshot") - servers_client._client.request.assert_called_with(url="/servers/1/actions/create_image", method="POST", json={"description": "my image", "type": "snapshot"}) + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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"} + ) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/create_image", + json={ + "description": "my image", + "type": "snapshot", + "labels": {"key": "value"}, + }, + ) assert response.action.id == 1 assert response.action.progress == 0 assert response.image.description == "my 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-16.04")) - servers_client._client.request.assert_called_with(url="/servers/1/actions/rebuild", method="POST", json={"image": "ubuntu-16.04"}) + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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, + ) - assert action.id == 1 - assert action.progress == 0 + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/rebuild", + json={"image": "ubuntu-20.04"}, + ) + + assert response.action.id == 1 + assert response.action.progress == 0 + assert response.root_password is None or isinstance(response.root_password, str) + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_enable_backup( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + action_response, + ): + request_mock.return_value = action_response - @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 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 - 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", method="POST", json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}) + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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") + ) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/attach_iso", + json={"iso": "FreeBSD-11.0-RELEASE-amd64-dvd1"}, + ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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", method="POST", json={"ip": "1.2.3.4", "dns_ptr": "example.com"}) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/change_dns_ptr", + json={"ip": "1.2.3.4", "dns_ptr": "example.com"}, + ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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", method="POST", json={"delete": True, "rebuild": True}) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/change_protection", + json={"delete": True, "rebuild": True}, + ) assert action.id == 1 assert action.progress == 0 - @pytest.mark.parametrize("server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))]) - def test_request_console(self, servers_client, server, response_server_request_console): - servers_client._client.request.return_value = response_server_request_console + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + def test_request_console( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + 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 assert response.action.progress == 0 - assert response.wss_url == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" + assert ( + response.wss_url + == "wss://console.hetzner.cloud/?server_id=1&token=3db32d15-af2f-459c-8bf8-dee1fd05f49c" + ) assert response.password == "9MQaTg2VAGI0FIpc10k3UpRXcHj2wQ6x" + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] + ) + def test_attach_to_network( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_attach_to_network, + ): + request_mock.return_value = response_attach_to_network + + action = servers_client.attach_to_network( + server, network, "10.0.1.1", ["10.0.1.2", "10.0.1.3"] + ) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/attach_to_network", + 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( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] + ) + def test_detach_from_network( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_detach_from_network, + ): + request_mock.return_value = response_detach_from_network + + action = servers_client.detach_from_network(server, network) + + request_mock.assert_called_with( + method="POST", + url="/servers/1/actions/detach_from_network", + json={"network": 4711}, + ) + + assert action.id == 1 + assert action.progress == 0 + assert action.command == "detach_from_network" + + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + @pytest.mark.parametrize( + "network", [Network(id=4711), BoundNetwork(mock.MagicMock(), dict(id=4711))] + ) + def test_change_alias_ips( + self, + request_mock: mock.MagicMock, + servers_client: ServersClient, + server, + network, + response_change_alias_ips, + ): + request_mock.return_value = response_change_alias_ips + + action = servers_client.change_alias_ips( + server, network, ["10.0.1.2", "10.0.1.3"] + ) + + request_mock.assert_called_with( + 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 222d6c08..2a80380c 100644 --- a/tests/unit/servers/test_domain.py +++ b/tests/unit/servers/test_domain.py @@ -1,11 +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.servers.domain import Server +from hcloud.networks import Network +from hcloud.servers import ( + BoundServer, + IPv4Address, + IPv6Network, + PrivateNet, + PublicNetwork, + PublicNetworkFirewall, + Server, + ServerCreatePublicNetwork, +) -class TestServer(object): +@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: 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)) + assert server.created == datetime.datetime( + 2016, 1, 30, 23, 50, tzinfo=timezone.utc + ) + + def test_private_net_for(self): + network1 = Network(id=1) + network2 = Network(id=2) + network3 = Network(id=3) + + server = Server( + id=42, + private_net=[ + PrivateNet( + network=network1, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + PrivateNet( + network=network2, ip="127.0.0.1", alias_ips=[], mac_address="" + ), + ], + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None + + server = BoundServer( + client=mock.MagicMock(), + data={ + "id": 42, + "private_net": [ + { + "network": 1, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + { + "network": 2, + "ip": "127.0.0.1", + "alias_ips": [], + "mac_address": "", + }, + ], + }, + ) + + assert server.private_net_for(network1).network.id == 1 + assert server.private_net_for(network3) is None diff --git a/tests/unit/ssh_keys/conftest.py b/tests/unit/ssh_keys/conftest.py index 2651ee54..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 @@ -9,7 +11,8 @@ def ssh_key_response(): "name": "My ssh key", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "public_key": "ssh-rsa AAAjjk76kgf...Xt", - "labels": {} + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", } } @@ -23,15 +26,17 @@ def two_ssh_keys_response(): "name": "SSH-Key", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "public_key": "ssh-rsa AAAjjk76kgf...Xt", - "labels": {} + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", }, { "id": 2324, "name": "SSH-Key", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "public_key": "ssh-rsa AAAjjk76kgf...Xt", - "labels": {} - } + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", + }, ] } @@ -45,7 +50,7 @@ def one_ssh_keys_response(): "name": "SSH-Key", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "public_key": "ssh-rsa AAAjjk76kgf...Xt", - "labels": {} + "labels": {}, } ] } @@ -59,6 +64,7 @@ def response_update_ssh_key(): "name": "New name", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "public_key": "ssh-rsa AAAjjk76kgf...Xt", - "labels": {} + "labels": {}, + "created": "2016-01-30T23:50:00+00:00", } } diff --git a/tests/unit/ssh_keys/test_client.py b/tests/unit/ssh_keys/test_client.py index a614d048..bed07b54 100644 --- a/tests/unit/ssh_keys/test_client.py +++ b/tests/unit/ssh_keys/test_client.py @@ -1,53 +1,62 @@ +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'] + client=mock.MagicMock(), data=ssh_key_response["ssh_key"] ) assert bound_ssh_key.id == 2323 assert bound_ssh_key.name == "My ssh key" - assert bound_ssh_key.fingerprint == "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" + assert ( + bound_ssh_key.fingerprint + == "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f" + ) 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 assert ssh_key.name == "My ssh key" @@ -55,14 +64,33 @@ def test_get_by_id(self, ssh_keys_client, ssh_key_response): @pytest.mark.parametrize( "params", [ - {'name': "My ssh key", "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", "label_selector": "k==v", 'page': 1, 'per_page': 10}, - {} - ] + { + "name": "My ssh key", + "fingerprint": "b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f", + "label_selector": "k==v", + "page": 1, + "per_page": 10, + }, + {"name": ""}, + {}, + ], ) - 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 assert len(ssh_keys) == 2 @@ -79,18 +107,26 @@ def test_get_list(self, ssh_keys_client, two_ssh_keys_response, params): assert ssh_keys2.name == "SSH-Key" @pytest.mark.parametrize( - "params", - [ - {'name': "My ssh key", 'label_selector': "label1"}, - {} - ] + "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) + params.update({"page": 1, "per_page": 50}) + + request_mock.assert_called_with( + method="GET", + url="/ssh_keys", + params=params, + ) assert len(ssh_keys) == 2 @@ -105,49 +141,113 @@ 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) + params = {"name": "SSH-Key"} + + 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 - ssh_keys = ssh_keys_client.get_by_fingerprint("b7:2f:30:a0:2f:6c:58:6c:21:04:58:61:ba:06:3b:2f") + 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"} - 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 - 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", method="POST", json={"name": "My ssh key", "public_key": "ssh-rsa AAAjjk76kgf...Xt"}) + 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" + ) + + request_mock.assert_called_with( + method="POST", + url="/ssh_keys", + json={"name": "My ssh key", "public_key": "ssh-rsa AAAjjk76kgf...Xt"}, + ) assert ssh_key.id == 2323 assert ssh_key.name == "My ssh key" - @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 + @pytest.mark.parametrize( + "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] + ) + 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 assert ssh_key.name == "New name" - @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 + @pytest.mark.parametrize( + "ssh_key", [SSHKey(id=1), BoundSSHKey(mock.MagicMock(), dict(id=1))] + ) + 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 new file mode 100644 index 00000000..9521f3a5 --- /dev/null +++ b/tests/unit/ssh_keys/test_domain.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import datetime +from datetime import timezone + +import pytest + +from hcloud.ssh_keys import SSHKey + + +@pytest.mark.parametrize( + "value", + [ + (SSHKey(id=1),), + ], +) +def test_eq(value): + assert value.__eq__(value) + + +class TestSSHKey: + def test_created_is_datetime(self): + 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/integration/ssh_keys/__init__.py b/tests/unit/storage_box_types/__init__.py similarity index 100% rename from tests/integration/ssh_keys/__init__.py rename to tests/unit/storage_box_types/__init__.py 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/integration/volumes/__init__.py b/tests/unit/storage_boxes/__init__.py similarity index 100% rename from tests/integration/volumes/__init__.py rename to tests/unit/storage_boxes/__init__.py 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..460a25da --- /dev/null +++ b/tests/unit/test_client.py @@ -0,0 +1,331 @@ +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="bad_gateway", message="Error", details=None), + True, + ), + ( + APIException(code=409, message="Conflict", details=None), + False, + ), + ( + APIException(code=429, message="Too Many Requests", details=None), + False, + ), + ( + APIException(code=502, message="Bad Gateway", details=None), + True, + ), + ( + APIException(code=503, message="Service Unavailable", details=None), + False, + ), + ( + APIException(code=504, message="Gateway Timeout", details=None), + True, + ), + ], + ) + def test_retry_policy( + self, + client: ClientBase, + exception: APIException, + expected: bool, + ): + assert client._retry_policy(exception) == expected + + def test_request_200(self, client: ClientBase): + client._session.request.return_value = make_response( + status=HTTPStatus.OK, + json={"result": "data"}, + ) + + 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 f3b7fcf9..00000000 --- a/tests/unit/test_hcloud.py +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -import json -import requests -import pytest -from hcloud import Client, APIException - - -class TestHetznerClient(object): - - @pytest.fixture() - def client(self): - Client._version = '0.0.0' - return Client(token="project_token") - - @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, mocked_requests, client, response): - mocked_requests.request.return_value = response - response = client.request("POST", "/servers", params={"argument": "value"}, timeout=2) - mocked_requests.request.assert_called_once() - assert mocked_requests.request.call_args[0] == ('POST', 'https://api.hetzner.cloud/v1/servers') - assert mocked_requests.request.call_args[1]['params'] == {'argument': 'value'} - assert mocked_requests.request.call_args[1]['timeout'] == 2 - assert response == {"result": "data"} - - def test_request_fails(self, mocked_requests, client, fail_response): - mocked_requests.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, mocked_requests, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "Internal Server Error" - mocked_requests.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, mocked_requests, client, response): - content = "{'key': 'value'".encode('utf-8') - response.reason = "OK" - response._content = content - mocked_requests.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, mocked_requests, client, response): - content = "" - response.reason = "OK" - response._content = content - mocked_requests.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, mocked_requests, client, fail_response): - fail_response.status_code = 500 - fail_response.reason = "Internal Server Error" - fail_response._content = "" - mocked_requests.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, mocked_requests, client, rate_limit_response): - client._retry_wait_time = 0 - mocked_requests.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 mocked_requests.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, mocked_requests, 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') - mocked_requests.request.side_effect = [rate_limit_response, response] - - client.request("POST", "http://url.com", params={"argument": "value"}, timeout=2) - assert mocked_requests.request.call_count == 2 diff --git a/tests/unit/volumes/conftest.py b/tests/unit/volumes/conftest.py index 7476b5dd..936ac6fa 100644 --- a/tests/unit/volumes/conftest.py +++ b/tests/unit/volumes/conftest.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import pytest @@ -16,16 +18,14 @@ def volume_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "format": "xfs", "labels": {}, - "status": "available" + "status": "available", } } @@ -46,16 +46,14 @@ def two_volumes_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "format": "xfs", "labels": {}, - "status": "available" + "status": "available", }, { "id": 2, @@ -69,17 +67,15 @@ def two_volumes_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "format": "xfs", "labels": {}, - "status": "available" - } + "status": "available", + }, ] } @@ -100,16 +96,14 @@ def one_volumes_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "format": "xfs", "labels": {}, - "status": "available" + "status": "available", } ] } @@ -130,16 +124,14 @@ def volume_create_response(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "format": "xfs", "labels": {}, - "status": "available" + "status": "available", }, "action": { "id": 13, @@ -148,16 +140,8 @@ def volume_create_response(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, }, "next_actions": [ { @@ -167,18 +151,10 @@ def volume_create_response(): "progress": 0, "started": "2016-01-30T23:50+00:00", "finished": None, - "resources": [ - { - "id": 42, - "type": "server" - } - ], - "error": { - "code": "action_failed", - "message": "Action failed" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, } - ] + ], } @@ -197,16 +173,14 @@ def response_update_volume(): "country": "DE", "city": "Falkenstein", "latitude": 50.47612, - "longitude": 12.370071 + "longitude": 12.370071, }, "format": "xfs", "size": 42, "linux_device": "/dev/disk/by-id/scsi-0HC_Volume_4711", - "protection": { - "delete": False - }, + "protection": {"delete": False}, "labels": {}, - "status": "available" + "status": "available", } } @@ -222,16 +196,8 @@ def response_get_actions(): "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" - } + "resources": [{"id": 42, "type": "server"}], + "error": {"code": "action_failed", "message": "Action failed"}, } ] } diff --git a/tests/unit/volumes/test_client.py b/tests/unit/volumes/test_client.py index f2c81bde..dd86f22b 100644 --- a/tests/unit/volumes/test_client.py +++ b/tests/unit/volumes/test_client.py @@ -1,26 +1,39 @@ +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(object): + +class TestBoundVolume(BoundModelTestCase): + methods = [ + BoundVolume.update, + BoundVolume.delete, + BoundVolume.change_protection, + BoundVolume.attach, + BoundVolume.detach, + BoundVolume.resize, + ] @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( - client=mock.MagicMock(), - data=volume_response['volume'] + client=mock.MagicMock(), data=volume_response["volume"] ) assert bound_volume.id == 1 @@ -43,115 +56,53 @@ 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 +class TestVolumesClient: + @pytest.fixture() + def volumes_client(self, client: Client): + return VolumesClient(client) - @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 + def test_get_by_id( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume_response, + ): + request_mock.return_value = volume_response - @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 + bound_volume = volumes_client.get_by_id(1) - 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} + request_mock.assert_called_with( + method="GET", + url="/volumes/1", ) - assert action.id == 1 - assert action.progress == 0 - - -class TestVolumesClient(object): - - @pytest.fixture() - def volumes_client(self): - return VolumesClient(client=mock.MagicMock()) - - 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") assert bound_volume._client is volumes_client assert bound_volume.id == 1 assert bound_volume.name == "database-storage" @pytest.mark.parametrize( "params", - [ - {'label_selector': "label1", 'page': 1, 'per_page': 10}, - {} - ] + [{"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 @@ -166,14 +117,25 @@ def test_get_list(self, volumes_client, two_volumes_response, params): assert bound_volume2.id == 2 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 + @pytest.mark.parametrize("params", [{"label_selector": "label1"}]) + 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}) + 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 @@ -188,37 +150,54 @@ 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"} + 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", location=Location(name="location"), automount=False, - format="xfs" + 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, - 'location': "location", - 'automount': False, - 'format': "xfs" - } + "name": "database-storage", + "size": 100, + "location": "location", + "automount": False, + "format": "xfs", + }, ) bound_volume = response.volume @@ -232,123 +211,205 @@ def test_create_with_location(self, volumes_client, volume_create_response): assert action.id == 13 assert next_actions[0].command == "start_server" - @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 + @pytest.mark.parametrize( + "server", [Server(id=1), BoundServer(mock.MagicMock(), dict(id=1))] + ) + 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", + size=100, + name="database-storage", server=server, automount=False, - format="xfs" + 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, - 'server': 1, - 'automount': False, - 'format': "xfs" - } + "name": "database-storage", + "size": 100, + "server": 1, + "automount": False, + "format": "xfs", + }, ) - 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") + -100, "database-storage", location=Location(name="location") ) assert str(e.value) == "size must be greater than 0" - volumes_client._client.request.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): + request_mock.assert_not_called() + + @pytest.mark.parametrize( + "location,server", [(None, None), ("location", Server(id=1))] + ) + def test_create_wrong_location_server_combination( + 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 + 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) + request_mock.assert_not_called() - assert actions[0]._client == volumes_client._client.actions - assert actions[0].id == 13 - assert actions[0].command == "attach_volume" + @pytest.mark.parametrize( + "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] + ) + def test_update( + self, + request_mock: mock.MagicMock, + volumes_client: VolumesClient, + volume, + response_update_volume, + ): + request_mock.return_value = response_update_volume - @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 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 assert volume.name == "new-name" - @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 + @pytest.mark.parametrize( + "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] + ) + 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", method="POST", json={"delete": True}) + + request_mock.assert_called_with( + method="POST", + url="/volumes/1/actions/change_protection", + json={"delete": True}, + ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "volume", [Volume(id=1), BoundVolume(mock.MagicMock(), dict(id=1))] + ) + 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 - @pytest.mark.parametrize("server,volume", - [(Server(id=1), Volume(id=12)), - (BoundServer(mock.MagicMock(), dict(id=1)), BoundVolume(mock.MagicMock(), dict(id=12)))]) - 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", + @pytest.mark.parametrize( + "server,volume", + [ + (Server(id=1), Volume(id=12)), + ( + BoundServer(mock.MagicMock(), dict(id=1)), + BoundVolume(mock.MagicMock(), dict(id=12)), + ), + ], + ) + 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", - json={"server": 1} + url="/volumes/12/actions/attach", + json={"server": 1, "automount": True}, ) assert action.id == 1 assert action.progress == 0 - @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 + @pytest.mark.parametrize( + "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] + ) + 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( + + request_mock.assert_called_with( + method="POST", url="/volumes/12/actions/detach", - method="POST" ) assert action.id == 1 assert action.progress == 0 - @pytest.mark.parametrize("volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))]) - def test_resize(self, volumes_client, volume, generic_action): - volumes_client._client.request.return_value = generic_action + @pytest.mark.parametrize( + "volume", [Volume(id=12), BoundVolume(mock.MagicMock(), dict(id=12))] + ) + 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", + + request_mock.assert_called_with( method="POST", - json={"size": 50} + 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 a4e4c327..50d54d43 100644 --- a/tests/unit/volumes/test_domain.py +++ b/tests/unit/volumes/test_domain.py @@ -1,11 +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 TestAction(object): +class TestVolume: def test_created_is_datetime(self): - server = Volume(id=1, created="2016-01-30T23:50+00:00") - assert server.created == datetime.datetime(2016, 1, 30, 23, 50, tzinfo=tzoffset(None, 0)) + volume = Volume(id=1, created="2016-01-30T23:50+00:00") + assert volume.created == datetime.datetime( + 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 1c609b21..813895b3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,17 @@ [tox] -envlist = py27, py34, py35, py36, py37, flake8 - -[testenv:flake8] -basepython = python -deps = flake8==3.6.0 -commands = flake8 hcloud tests setup.py +envlist = py310, py311, py312, py313, py314 [testenv] passenv = FAKE_API_ENDPOINT deps = - -r{toxinidir}/requirements/test.txt + -e.[test] commands = - pytest - + pytest tests/unit {posargs} +[gh-actions] +python = + 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 + 3.14: py314