diff --git a/.github/workflows/e2e-test-pr.yml b/.github/workflows/e2e-test-pr.yml index 4d44d48d3..b90ee1796 100644 --- a/.github/workflows/e2e-test-pr.yml +++ b/.github/workflows/e2e-test-pr.yml @@ -71,14 +71,6 @@ jobs: - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Install Python SDK run: make dev-install env: @@ -92,13 +84,6 @@ jobs: env: LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Apply Calico Rules to LKE - if: always() - run: | - cd scripts && ./lke_calico_rules_e2e.sh - env: - LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} - - name: Upload test results if: always() run: | @@ -141,3 +126,60 @@ jobs: conclusion: process.env.conclusion }); return result; + + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ secrets.LINODE_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-fork-ubuntu] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ secrets.LINODE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 848154b55..e02b708e1 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -11,16 +11,30 @@ on: description: 'The hash value of the commit' required: false default: '' + python-version: + description: 'Specify Python version to use' + required: false + run-eol-python-version: + description: 'Run EOL python version?' + required: false + default: 'false' + type: choice + options: + - 'true' + - 'false' push: branches: - main - dev +env: + DEFAULT_PYTHON_VERSION: "3.9" + EOL_PYTHON_VERSION: "3.8" + EXIT_STATUS: 0 + jobs: integration-tests: runs-on: ubuntu-latest - env: - EXIT_STATUS: 0 steps: - name: Clone Repository with SHA if: ${{ inputs.sha != '' }} @@ -40,7 +54,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v5 with: - python-version: '3.x' + python-version: ${{ inputs.run-eol-python-version == 'true' && env.EOL_PYTHON_VERSION || inputs.python-version || env.DEFAULT_PYTHON_VERSION }} - name: Install Python deps run: pip install -U setuptools wheel boto3 certifi @@ -50,14 +64,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Download kubectl and calicoctl for LKE clusters - run: | - curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" - curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" - chmod +x calicoctl-linux-amd64 kubectl - mv calicoctl-linux-amd64 /usr/local/bin/calicoctl - mv kubectl /usr/local/bin/kubectl - - name: Set LINODE_TOKEN run: | echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV @@ -70,13 +76,6 @@ jobs: env: LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Apply Calico Rules to LKE - if: always() - run: | - cd scripts && ./lke_calico_rules_e2e.sh - env: - LINODE_TOKEN: ${{ env.LINODE_TOKEN }} - - name: Upload test results if: always() run: | @@ -92,10 +91,75 @@ jobs: LINODE_CLI_OBJ_ACCESS_KEY: ${{ secrets.LINODE_CLI_OBJ_ACCESS_KEY }} LINODE_CLI_OBJ_SECRET_KEY: ${{ secrets.LINODE_CLI_OBJ_SECRET_KEY }} + apply-calico-rules: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: 'recursive' + + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + + - name: Download kubectl and calicoctl for LKE clusters + run: | + curl -LO "https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/amd64/kubectl" + curl -LO "https://github.com/projectcalico/calico/releases/download/v3.25.0/calicoctl-linux-amd64" + chmod +x calicoctl-linux-amd64 kubectl + mv calicoctl-linux-amd64 /usr/local/bin/calicoctl + mv kubectl /usr/local/bin/kubectl + + - name: Apply Calico Rules to LKE + run: | + cd e2e_scripts/cloud_security_scripts/lke_calico_rules/ && ./lke_calico_rules_e2e.sh + env: + LINODE_TOKEN: ${{ env.LINODE_TOKEN }} + + add-fw-to-remaining-instances: + runs-on: ubuntu-latest + needs: [integration-tests] + if: ${{ success() || failure() }} + + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install Linode CLI + run: | + pip install linode-cli + + - name: Set LINODE_TOKEN + run: | + echo "LINODE_TOKEN=${{ secrets[inputs.use_minimal_test_account == 'true' && 'MINIMAL_LINODE_TOKEN' || 'LINODE_TOKEN'] }}" >> $GITHUB_ENV + + - name: Create Firewall and Attach to Instances + run: | + FIREWALL_ID=$(linode-cli firewalls create --label "e2e-fw-$(date +%s)" --rules.inbound_policy "DROP" --rules.outbound_policy "ACCEPT" --text --format=id --no-headers) + echo "Created Firewall with ID: $FIREWALL_ID" + + for instance_id in $(linode-cli linodes list --format "id" --text --no-header); do + echo "Attaching firewall to instance: $instance_id" + if linode-cli firewalls device-create "$FIREWALL_ID" --id "$instance_id" --type linode; then + echo "Firewall attached to instance $instance_id successfully." + else + echo "An error occurred while attaching firewall to instance $instance_id. Skipping..." + fi + done + env: + LINODE_CLI_TOKEN: ${{ env.LINODE_TOKEN }} + notify-slack: runs-on: ubuntu-latest needs: [integration-tests] - if: always() && github.repository == 'linode/linode_api4-python' # Run even if integration tests fail and only on main repository + if: ${{ (success() || failure()) && github.repository == 'linode/linode_api4-python' }} # Run even if integration tests fail and only on main repository steps: - name: Notify Slack diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee290360c..f29a4529f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8','3.9','3.10','3.11', '3.12'] + python-version: ['3.9','3.10','3.11', '3.12'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.github/workflows/release-notify-slack.yml b/.github/workflows/release-notify-slack.yml new file mode 100644 index 000000000..aa3d30af4 --- /dev/null +++ b/.github/workflows/release-notify-slack.yml @@ -0,0 +1,30 @@ +name: Notify Dev DX Channel on Release +on: + release: + types: [published] + workflow_dispatch: null + +jobs: + notify: + if: github.repository == 'linode/linode_api4-python' + runs-on: ubuntu-latest + steps: + - name: Notify Slack - Main Message + id: main_message + uses: slackapi/slack-github-action@v1.27.0 + with: + channel-id: ${{ secrets.DEV_DX_SLACK_CHANNEL_ID }} + payload: | + { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*New Release Published: _linode_api4-python_ <${{ github.event.release.html_url }}|${{ github.event.release.tag_name }}> is now live!* :tada:" + } + } + ] + } + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} \ No newline at end of file diff --git a/linode_api4/errors.py b/linode_api4/errors.py index bc2df6108..511ac8c57 100644 --- a/linode_api4/errors.py +++ b/linode_api4/errors.py @@ -1,4 +1,11 @@ +# Necessary to maintain compatibility with Python < 3.11 +from __future__ import annotations + from builtins import super +from json import JSONDecodeError +from typing import Any, Dict, Optional + +from requests import Response class ApiError(RuntimeError): @@ -8,14 +15,90 @@ class ApiError(RuntimeError): often, this will be caused by invalid input to the API. """ - def __init__(self, message, status=400, json=None): + def __init__( + self, + message: str, + status: int = 400, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + self.errors = [] + if json and "errors" in json and isinstance(json["errors"], list): self.errors = [e["reason"] for e in json["errors"]] + @classmethod + def from_response( + cls, + response: Response, + message: Optional[str] = None, + disable_formatting: bool = False, + ) -> Optional[ApiError]: + """ + Creates an ApiError object from the given response, + or None if the response does not contain an error. + + :arg response: The response to create an ApiError from. + :arg message: An optional message to prepend to the error's message. + :arg disable_formatting: If true, the error's message will not automatically be formatted + with details from the API response. + + :returns: The new API error. + """ + + if response.status_code < 400 or response.status_code > 599: + # No error was found + return None + + request = response.request + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + # Use the user-defined message is formatting is disabled + if disable_formatting: + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) + + # Build the error string + error_fmt = "N/A" + + if response_json is not None and "errors" in response_json: + errors = [] + + for error in response_json["errors"]: + field = error.get("field") + reason = error.get("reason") + errors.append(f"{field + ': ' if field else ''}{reason}") + + error_fmt = "; ".join(errors) + + elif len(response.text or "") > 0: + error_fmt = response.text + + return cls( + ( + f"{message + ': ' if message is not None else ''}" + f"{f'{request.method} {request.path_url}: ' if request else ''}" + f"[{response.status_code}] {error_fmt}" + ), + status=response.status_code, + json=response_json, + response=response, + ) + class UnexpectedResponseError(RuntimeError): """ @@ -26,7 +109,41 @@ class UnexpectedResponseError(RuntimeError): library, and should be fixed with changes to this codebase. """ - def __init__(self, message, status=200, json=None): + def __init__( + self, + message: str, + status: int = 200, + json: Optional[Dict[str, Any]] = None, + response: Optional[Response] = None, + ): super().__init__(message) + self.status = status self.json = json + self.response = response + + @classmethod + def from_response( + cls, + message: str, + response: Response, + ) -> Optional[UnexpectedResponseError]: + """ + Creates an UnexpectedResponseError object from the given response and message. + + :arg message: The message to create this error with. + :arg response: The response to create an UnexpectedResponseError from. + :returns: The new UnexpectedResponseError. + """ + + try: + response_json = response.json() + except JSONDecodeError: + response_json = None + + return cls( + message, + status=response.status_code, + json=response_json, + response=response, + ) diff --git a/linode_api4/groups/lke.py b/linode_api4/groups/lke.py index b60090595..d0de66f37 100644 --- a/linode_api4/groups/lke.py +++ b/linode_api4/groups/lke.py @@ -161,7 +161,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`LKEType` objects that represents a valid LKE type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/networking.py b/linode_api4/groups/networking.py index 5d49e9bb3..4820b706d 100644 --- a/linode_api4/groups/networking.py +++ b/linode_api4/groups/networking.py @@ -354,7 +354,7 @@ def transfer_prices(self, *filters): """ Returns a :any:`PaginatedList` of :any:`NetworkTransferPrice` objects that represents a valid network transfer price. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/nodebalancer.py b/linode_api4/groups/nodebalancer.py index acc1f07e2..57830c8c4 100644 --- a/linode_api4/groups/nodebalancer.py +++ b/linode_api4/groups/nodebalancer.py @@ -55,7 +55,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`NodeBalancerType` objects that represents a valid NodeBalancer type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/groups/volume.py b/linode_api4/groups/volume.py index 3a30de762..6e879c3d6 100644 --- a/linode_api4/groups/volume.py +++ b/linode_api4/groups/volume.py @@ -78,7 +78,7 @@ def types(self, *filters): """ Returns a :any:`PaginatedList` of :any:`VolumeType` objects that represents a valid Volume type. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types :param filters: Any number of filters to apply to this query. See :doc:`Filtering Collections` diff --git a/linode_api4/linode_client.py b/linode_api4/linode_client.py index 1bbc631b7..dbb45d0df 100644 --- a/linode_api4/linode_client.py +++ b/linode_api4/linode_client.py @@ -287,23 +287,9 @@ def _api_call( if warning: logger.warning("Received warning from server: {}".format(warning)) - if 399 < response.status_code < 600: - j = None - error_msg = "{}: ".format(response.status_code) - try: - j = response.json() - if "errors" in j.keys(): - for e in j["errors"]: - msg = e.get("reason", "") - field = e.get("field", None) - - error_msg += "{}{}; ".format( - f"[{field}] " if field is not None else "", - msg, - ) - except: - pass - raise ApiError(error_msg, status=response.status_code, json=j) + api_error = ApiError.from_response(response) + if api_error is not None: + raise api_error if response.status_code != 204: j = response.json() diff --git a/linode_api4/login_client.py b/linode_api4/login_client.py index 1263ee49c..e21c5c4b2 100644 --- a/linode_api4/login_client.py +++ b/linode_api4/login_client.py @@ -434,10 +434,9 @@ def oauth_redirect(): ) if r.status_code != 200: - raise ApiError( - "OAuth token exchange failed", - status=r.status_code, - json=r.json(), + raise ApiError.from_response( + r, + message="OAuth token exchange failed", ) token = r.json()["access_token"] @@ -479,7 +478,7 @@ def refresh_oauth_token(self, refresh_token): ) if r.status_code != 200: - raise ApiError("Refresh failed", r) + raise ApiError.from_response(r, message="Refresh failed") token = r.json()["access_token"] scopes = OAuthScopes.parse(r.json()["scopes"]) @@ -516,5 +515,5 @@ def expire_token(self, token): ) if r.status_code != 200: - raise ApiError("Failed to expire token!", r) + raise ApiError.from_response(r, "Failed to expire token!") return True diff --git a/linode_api4/objects/account.py b/linode_api4/objects/account.py index 4777ff1c4..375e5fc03 100644 --- a/linode_api4/objects/account.py +++ b/linode_api4/objects/account.py @@ -436,8 +436,10 @@ def thumbnail(self, dump_to=None): ) if not result.status_code == 200: - raise ApiError( - "No thumbnail found for OAuthClient {}".format(self.id) + raise ApiError.from_response( + result, + "No thumbnail found for OAuthClient {}".format(self.id), + disable_formatting=True, ) if dump_to: @@ -472,12 +474,9 @@ def set_thumbnail(self, thumbnail): data=thumbnail, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True diff --git a/linode_api4/objects/lke.py b/linode_api4/objects/lke.py index 1c2ed3c1a..7ff6b0fd8 100644 --- a/linode_api4/objects/lke.py +++ b/linode_api4/objects/lke.py @@ -22,7 +22,7 @@ class LKEType(Base): Currently the LKEType can only be retrieved by listing, i.e.: types = client.lke.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-types """ properties = { @@ -338,7 +338,7 @@ def control_plane_acl(self) -> LKEClusterControlPlaneACL: NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/get-lke-cluster-acl :returns: The cluster's control plane ACL configuration. :rtype: LKEClusterControlPlaneACL @@ -529,7 +529,7 @@ def control_plane_acl_update( NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/put-lke-cluster-acl :param acl: The ACL configuration to apply to this cluster. :type acl: LKEClusterControlPlaneACLOptions or Dict[str, Any] @@ -560,7 +560,7 @@ def control_plane_acl_delete(self): NOTE: Control Plane ACLs may not currently be available to all users. - API Documentation: TODO + API Documentation: https://techdocs.akamai.com/linode-api/reference/delete-lke-cluster-acl """ self._client.delete( f"{LKECluster.api_endpoint}/control_plane_acl", model=self diff --git a/linode_api4/objects/networking.py b/linode_api4/objects/networking.py index c4fff1ac3..613eca21c 100644 --- a/linode_api4/objects/networking.py +++ b/linode_api4/objects/networking.py @@ -268,7 +268,7 @@ class NetworkTransferPrice(Base): Currently the NetworkTransferPrice can only be retrieved by listing, i.e.: types = client.networking.transfer_prices() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-network-transfer-prices """ properties = { diff --git a/linode_api4/objects/nodebalancer.py b/linode_api4/objects/nodebalancer.py index d038b6998..840d5b965 100644 --- a/linode_api4/objects/nodebalancer.py +++ b/linode_api4/objects/nodebalancer.py @@ -15,7 +15,7 @@ class NodeBalancerType(Base): Currently the NodeBalancerType can only be retrieved by listing, i.e.: types = client.nodebalancers.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-node-balancer-types """ properties = { diff --git a/linode_api4/objects/support.py b/linode_api4/objects/support.py index f835b3f31..548f58f16 100644 --- a/linode_api4/objects/support.py +++ b/linode_api4/objects/support.py @@ -174,12 +174,9 @@ def upload_attachment(self, attachment: Union[Path, str]): files={"file": f}, ) - if not result.status_code == 200: - errors = [] - j = result.json() - if "errors" in j: - errors = [e["reason"] for e in j["errors"]] - raise ApiError("{}: {}".format(result.status_code, errors), json=j) + api_exc = ApiError.from_response(result) + if api_exc is not None: + raise api_exc return True diff --git a/linode_api4/objects/volume.py b/linode_api4/objects/volume.py index 58764e8d7..6d49f72c9 100644 --- a/linode_api4/objects/volume.py +++ b/linode_api4/objects/volume.py @@ -11,7 +11,7 @@ class VolumeType(Base): Currently the VolumeType can only be retrieved by listing, i.e.: types = client.volumes.types() - API documentation: TODO + API documentation: https://techdocs.akamai.com/linode-api/reference/get-volume-types """ properties = { diff --git a/test/integration/linode_client/test_errors.py b/test/integration/linode_client/test_errors.py new file mode 100644 index 000000000..2c3ab57b5 --- /dev/null +++ b/test/integration/linode_client/test_errors.py @@ -0,0 +1,28 @@ +from linode_api4.errors import ApiError + + +def test_error_404(test_linode_client): + api_exc = None + + try: + test_linode_client.get("/invalid/endpoint") + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == "GET /v4beta/invalid/endpoint: [404] Not found" + + +def test_error_400(test_linode_client): + api_exc = None + + try: + test_linode_client.linode.instance_create( + "g6-fake-plan", "us-fakeregion" + ) + except ApiError as exc: + api_exc = exc + + assert str(api_exc) == ( + "POST /v4beta/linode/instances: [400] type: A valid plan type by that ID was not found; " + "region: region is not valid" + ) diff --git a/test/integration/models/linode/test_linode.py b/test/integration/models/linode/test_linode.py index 6d461cdf1..998a0c89a 100644 --- a/test/integration/models/linode/test_linode.py +++ b/test/integration/models/linode/test_linode.py @@ -392,7 +392,7 @@ def test_linode_resize_with_migration_type( # there is no resizing state in warm migration anymore hence wait for resizing and poll event test_linode_client.polling.event_poller_create( "linode", "linode_resize", entity_id=linode.id - ).wait_for_next_event_finished(interval=5) + ).wait_for_next_event_finished(interval=5, timeout=500) wait_for_condition( 10, diff --git a/test/unit/errors_test.py b/test/unit/errors_test.py new file mode 100644 index 000000000..017c96280 --- /dev/null +++ b/test/unit/errors_test.py @@ -0,0 +1,104 @@ +from types import SimpleNamespace +from unittest import TestCase + +from linode_api4.errors import ApiError, UnexpectedResponseError + + +class ApiErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "errors": [ + {"reason": "foo"}, + {"field": "bar", "reason": "oh no"}, + ] + }, + text='{"errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}]}', + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [400] foo; bar: oh no" + assert exc.status == 400 + assert exc.json == { + "errors": [{"reason": "foo"}, {"field": "bar", "reason": "oh no"}] + } + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_non_json_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text="foobar", + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_empty_body(self): + mock_response = SimpleNamespace( + status_code=500, + json=lambda: None, + text=None, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "POST foo/bar: [500] N/A" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar" + + def test_from_response_no_request(self): + mock_response = SimpleNamespace( + status_code=500, json=lambda: None, text="foobar", request=None + ) + + exc = ApiError.from_response(mock_response) + + assert str(exc) == "[500] foobar" + assert exc.status == 500 + assert exc.json is None + assert exc.response.request is None + + +class UnexpectedResponseErrorTest(TestCase): + def test_from_response(self): + mock_response = SimpleNamespace( + status_code=400, + json=lambda: { + "foo": "bar", + }, + request=SimpleNamespace( + method="POST", + path_url="foo/bar", + ), + ) + + exc = UnexpectedResponseError.from_response("foobar", mock_response) + + assert str(exc) == "foobar" + assert exc.status == 400 + assert exc.json == {"foo": "bar"} + assert exc.response.request.method == "POST" + assert exc.response.request.path_url == "foo/bar"