Repository: onicagroup/runway

Issue:
[TODO] improve `runway.core.providers.aws._response.BaseResponse`
- use pydantic

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup) Report
> Merging [#1132](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup) (21ff682) into [master](https://codecov.io/gh/onicagroup/runway/commit/a3174978c5053ac93d021390ed7990e10864f349?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup) (a317497) will **not change** coverage.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/onicagroup/runway/pull/1132/graphs/tree.svg?width=650&height=150&src=pr&token=Ku28I0RY80&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup)](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup)

```diff
@@           Coverage Diff           @@
##           master    #1132   +/-   ##
=======================================
  Coverage   89.48%   89.48%           
=======================================
  Files         247      247           
  Lines       15295    15295           
=======================================
  Hits        13686    13686           
  Misses       1609     1609           
```


| [Impacted Files](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup) | Coverage Δ | |
|---|---|---|
| [runway/core/providers/aws/\_response.py](https://codecov.io/gh/onicagroup/runway/pull/1132/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup#diff-cnVud2F5L2NvcmUvcHJvdmlkZXJzL2F3cy9fcmVzcG9uc2UucHk=) | `100.00% <100.00%> (ø)` | |
| [runway/core/providers/aws/s3/\_bucket.py](https://codecov.io/gh/onicagroup/runway/pull/1132/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup#diff-cnVud2F5L2NvcmUvcHJvdmlkZXJzL2F3cy9zMy9fYnVja2V0LnB5) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup). Last update [a317497...21ff682](https://codecov.io/gh/onicagroup/runway/pull/1132?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=onicagroup).


Patch:
diff --git a/runway/core/providers/aws/_response.py b/runway/core/providers/aws/_response.py
index 8727a65d..687c7c2f 100644
--- a/runway/core/providers/aws/_response.py
+++ b/runway/core/providers/aws/_response.py
@@ -1,75 +1,64 @@
 """Base class for AWS responses."""
 # pylint: disable=invalid-name
 from http import HTTPStatus
-from typing import Any, Dict, Optional
+from typing import Any, Dict
 
+from pydantic import Field
 
-class ResponseError:
+from ....utils import BaseModel
+
+
+class ResponseError(BaseModel):
     """Analyse the response from AWS S3 HeadBucket API response.
 
-    Attributes:
-        code: A unique short code representing the error that was emitted.
-        message: A longer human readable error message.
+    Keyword Args:
+        Code: A unique short code representing the error that was emitted.
+        Message: A longer human readable error message.
 
     """
 
-    def __init__(self, *, Code: str = "", Message: str = "") -> None:  # noqa
-        """Instantiate class.
-
-        Args:
-            Code: A unique short code representing the error that was emitted.
-            Message: A longer human readable error message.
+    code: str = Field(default="", alias="Code")
+    """A unique short code representing the error that was emitted."""
 
-        """
-        self.code = Code
-        self.message = Message
+    message: str = Field(default="", alias="Message")
+    """A longer human readable error message."""
 
     def __bool__(self) -> bool:
         """Implement evaluation of instances as a bool."""
         return bool(self.code or self.message)
 
 
-class ResponseMetadata:
+class ResponseMetadata(BaseModel):
     """Analyse the response from AWS S3 HeadBucket API response.
 
-    Attributes:
-        host_id: Host ID data.
-        https_headers: A map of response header keys and
-            their respective values.
-        http_status_code: The HTTP status code of the response (e.g., 200, 404).
-        request_id: The unique request ID associated with the response.
+    Keyword Args:
+        HostId: Host ID data.
+        HTTPHeaders: A map of response header keys and their respective values.
+        HTTPStatusCode: The HTTP status code of the response (e.g., 200, 404).
+        RequestId: The unique request ID associated with the response.
             Log this value when debugging requests for AWS support.
-        retry_attempts: The number of retries that were attempted
-            before the request was completed.
+        RetryAttempts: The number of retries that were attempted before the
+            request was completed.
+
+    """
+
+    host_id: str = Field(default="", alias="HostId")
+    """Host ID data."""
+
+    https_headers: Dict[str, Any] = Field(default={}, alias="HTTPHeaders")
+    """A map of response header keys and their respective values."""
+
+    http_status_code: int = Field(default=200, alias="HTTPStatusCode")
+    """he HTTP status code of the response (e.g., 200, 404)."""
+
+    request_id: str = Field(default="", alias="RequestId")
+    """The unique request ID associated with the response.
+    Log this value when debugging requests for AWS support.
 
     """
 
-    def __init__(
-        self,
-        *,
-        HostId: Optional[str] = None,  # noqa
-        HTTPHeaders: Optional[Dict[str, Any]] = None,  # noqa
-        HTTPStatusCode: int = 200,  # noqa
-        RequestId: Optional[str] = None,  # noqa
-        RetryAttempts: int = 0,  # noqa
-    ) -> None:
-        """Instantiate class.
-
-        Keyword Args:
-            HostId: Host ID data.
-            HTTPHeaders: A map of response header keys and their respective values.
-            HTTPStatusCode: The HTTP status code of the response (e.g., 200, 404).
-            RequestId: The unique request ID associated with the response.
-                Log this value when debugging requests for AWS support.
-            RetryAttempts: The number of retries that were attempted before the
-                request was completed.
-
-        """
-        self.host_id = HostId
-        self.https_headers = HTTPHeaders or {}
-        self.http_status_code = HTTPStatusCode
-        self.request_id = RequestId
-        self.retry_attempts = RetryAttempts
+    retry_attempts: int = Field(default=0, alias="RetryAttempts")
+    """The number of retries that were attempted before the request was completed."""
 
     @property
     def forbidden(self) -> bool:
@@ -82,22 +71,19 @@ class ResponseMetadata:
         return self.http_status_code == HTTPStatus.NOT_FOUND
 
 
-class BaseResponse:
+class BaseResponse(BaseModel):
     """Analyse the response from AWS S3 HeadBucket API response.
 
-    Attributes:
-        error: Information about a service or networking error.
-        metadata: Information about the request.
+    Keyword Args:
+        Error: Information about a service or networking error.
+        ResponseMetadata: Information about the request.
 
     """
 
-    def __init__(self, **kwargs: Any) -> None:
-        """Instantiate class.
-
-        Keyword Args:
-            Error: Information about a service or networking error.
-            ResponseMetadata: Information about the request.
+    error: ResponseError = Field(default=ResponseError(), alias="Error")
+    """Information about a service or networking error."""
 
-        """
-        self.error = ResponseError(**kwargs.pop("Error", {}))  # type: ignore
-        self.metadata = ResponseMetadata(**kwargs.pop("ResponseMetadata", {}))  # type: ignore
+    metadata: ResponseMetadata = Field(
+        default=ResponseMetadata(), alias="ResponseMetadata"
+    )
+    """Information about the request."""
diff --git a/runway/core/providers/aws/s3/_bucket.py b/runway/core/providers/aws/s3/_bucket.py
index 4a69174a..fe0faffa 100644
--- a/runway/core/providers/aws/s3/_bucket.py
+++ b/runway/core/providers/aws/s3/_bucket.py
@@ -83,7 +83,7 @@ class Bucket(DelCachedPropMixin):
                 self.name,
                 exc_info=True,
             )
-            return BaseResponse(**err.response)
+            return BaseResponse.parse_obj(err.response)
 
     @cached_property
     def not_found(self) -> bool:


Test patch:
diff --git a/tests/unit/core/providers/aws/test_response.py b/tests/unit/core/providers/aws/test_response.py
index 1c2f86ef..703ebfa7 100644
--- a/tests/unit/core/providers/aws/test_response.py
+++ b/tests/unit/core/providers/aws/test_response.py
@@ -3,41 +3,29 @@
 # pyright: basic
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
-
 from runway.core.providers.aws import BaseResponse, ResponseError, ResponseMetadata
 
-if TYPE_CHECKING:
-    from pytest_mock import MockerFixture
-
 MODULE = "runway.core.providers.aws._response"
 
 
 class TestBaseResponse:
     """Test runway.core.providers.aws._response.BaseResponse."""
 
-    def test_init(self, mocker: MockerFixture) -> None:
+    def test_init(self) -> None:
         """Test init and the attributes it sets."""
-        mock_error = mocker.patch(f"{MODULE}.ResponseError")
-        mock_metadata = mocker.patch(f"{MODULE}.ResponseMetadata")
         data = {"Error": {"Code": "something"}, "ResponseMetadata": {"HostId": "id"}}
         response = BaseResponse(**data.copy())
 
-        assert response.error == mock_error.return_value
-        assert response.metadata == mock_metadata.return_value
-        mock_error.assert_called_once_with(**data["Error"])
-        mock_metadata.assert_called_once_with(**data["ResponseMetadata"])
+        assert isinstance(response.error, ResponseError)
+        assert response.error.code == data["Error"]["Code"]
+        assert isinstance(response.metadata, ResponseMetadata)
+        assert response.metadata.host_id == data["ResponseMetadata"]["HostId"]
 
-    def test_init_default(self, mocker: MockerFixture) -> None:
+    def test_init_default(self) -> None:
         """Test init default values and the attributes it sets."""
-        mock_error = mocker.patch(f"{MODULE}.ResponseError")
-        mock_metadata = mocker.patch(f"{MODULE}.ResponseMetadata")
         response = BaseResponse()
-
-        assert response.error == mock_error.return_value
-        assert response.metadata == mock_metadata.return_value
-        mock_error.assert_called_once_with()
-        mock_metadata.assert_called_once_with()
+        assert response.error == ResponseError()
+        assert response.metadata == ResponseMetadata()
 
 
 class TestResponseError: