From 16ac0abc19b88a541b518177810a0ac7a97ccf89 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 28 Jul 2025 12:17:18 +0300 Subject: [PATCH 01/13] Fix issue #29. Add support of Emails Sandbox (Testing) API: Projects --- mailtrap/api/base.py | 71 ++++++++++++ mailtrap/api/projects.py | 50 ++++++++ mailtrap/client.py | 12 +- mailtrap/constants.py | 1 + mailtrap/models/common.py | 3 + mailtrap/models/projects.py | 50 ++++++++ tests/unit/api/__init__.py | 0 tests/unit/api/test_projects.py | 194 ++++++++++++++++++++++++++++++++ 8 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 mailtrap/api/base.py create mode 100644 mailtrap/api/projects.py create mode 100644 mailtrap/constants.py create mode 100644 mailtrap/models/common.py create mode 100644 mailtrap/models/projects.py create mode 100644 tests/unit/api/__init__.py create mode 100644 tests/unit/api/test_projects.py diff --git a/mailtrap/api/base.py b/mailtrap/api/base.py new file mode 100644 index 0000000..3ad6837 --- /dev/null +++ b/mailtrap/api/base.py @@ -0,0 +1,71 @@ +from abc import ABC +from enum import Enum +from typing import Any, Dict, List, NoReturn, Union, cast +from requests import Response, Session + +from mailtrap.exceptions import APIError, AuthorizationError + +RESPONSE_TYPE = Dict[str, Any] +LIST_RESPONSE_TYPE = List[Dict[str, Any]] + + +class HttpMethod(Enum): + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" + DELTE = "DELETE" + + +def _extract_errors(data: Dict[str, Any]) -> List[str]: + if "errors" in data: + errors = data["errors"] + + if isinstance(errors, list): + return [str(err) for err in errors] + + if isinstance(errors, dict): + flat_errors = [] + for key, value in errors.items(): + if isinstance(value, list): + flat_errors.extend([f"{key}: {v}" for v in value]) + else: + flat_errors.append(f"{key}: {value}") + return flat_errors + + return [str(errors)] + + elif "error" in data: + return [str(data["error"])] + + return ["Unknown error"] + + +class BaseHttpApiClient(ABC): + def __init__(self, session: Session): + self.session = session + + def _request(self, method: HttpMethod, url: str, **kwargs: Any) -> Union[RESPONSE_TYPE, LIST_RESPONSE_TYPE]: + response = self.session.request(method.value, url, **kwargs) + if response.ok: + data = cast(Union[RESPONSE_TYPE, LIST_RESPONSE_TYPE], response.json()) + return data + + self._handle_failed_response(response) + + @staticmethod + def _handle_failed_response(response: Response) -> NoReturn: + status_code = response.status_code + + try: + data = response.json() + except ValueError: + raise APIError(status_code, errors=["Unknown Error"]) + + errors = _extract_errors(data) + + if status_code == 401: + raise AuthorizationError(errors=errors) + + raise APIError(status_code, errors=errors) + diff --git a/mailtrap/api/projects.py b/mailtrap/api/projects.py new file mode 100644 index 0000000..5efa3e9 --- /dev/null +++ b/mailtrap/api/projects.py @@ -0,0 +1,50 @@ +from typing import List, cast + +from mailtrap.api.base import LIST_RESPONSE_TYPE, RESPONSE_TYPE, BaseHttpApiClient, HttpMethod +from mailtrap.constants import MAILTRAP_HOST +from mailtrap.models.common import DeletedObject +from mailtrap.models.projects import Project + + +class ProjectsApiClient(BaseHttpApiClient): + + def _build_url(self, account_id: str, *parts: str) -> str: + base_url = f"https://{MAILTRAP_HOST}/api/accounts/{account_id}/projects" + return "/".join([base_url, *parts]) + + def get_list(self, account_id: str) -> List[Project]: + response: LIST_RESPONSE_TYPE = cast(LIST_RESPONSE_TYPE, self._request( + HttpMethod.GET, + self._build_url(account_id) + )) + return [Project.from_dict(proj) for proj in response] + + def get_by_id(self, account_id: str, project_id: str) -> Project: + response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( + HttpMethod.GET, + self._build_url(account_id, project_id) + )) + return Project.from_dict(response) + + def create(self, account_id: str, name: str) -> Project: + response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( + HttpMethod.POST, + self._build_url(account_id), + json={"project": {"name": name}}, + )) + return Project.from_dict(response) + + def update(self, account_id: str, project_id: str, name: str) -> Project: + response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( + HttpMethod.PATCH, + self._build_url(account_id, project_id), + json={"project": {"name": name}}, + )) + return Project.from_dict(response) + + def delete(self, account_id: str, project_id: str) -> DeletedObject: + response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( + HttpMethod.DELTE, + self._build_url(account_id, project_id), + )) + return DeletedObject(response["id"]) diff --git a/mailtrap/client.py b/mailtrap/client.py index 60a1d6a..834f5cc 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -4,6 +4,7 @@ import requests +from mailtrap.api.projects import ProjectsApiClient from mailtrap.exceptions import APIError from mailtrap.exceptions import AuthorizationError from mailtrap.exceptions import ClientConfigurationError @@ -34,10 +35,15 @@ def __init__( self._validate_itself() + self._http_client = requests.Session() + self._http_client.headers.update(self.headers) + + @property + def projects_api(self) -> ProjectsApiClient: + return ProjectsApiClient(self._http_client) + def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: - response = requests.post( - self.api_send_url, headers=self.headers, json=mail.api_data - ) + response = self._http_client.post(self.api_send_url, json=mail.api_data) if response.ok: data: dict[str, Union[bool, list[str]]] = response.json() diff --git a/mailtrap/constants.py b/mailtrap/constants.py new file mode 100644 index 0000000..58d4257 --- /dev/null +++ b/mailtrap/constants.py @@ -0,0 +1 @@ +MAILTRAP_HOST = "mailtrap.io" diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py new file mode 100644 index 0000000..7643b12 --- /dev/null +++ b/mailtrap/models/common.py @@ -0,0 +1,3 @@ +class DeletedObject: + def __init__(self, id: str): + self.id = id diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py new file mode 100644 index 0000000..2371875 --- /dev/null +++ b/mailtrap/models/projects.py @@ -0,0 +1,50 @@ +from typing import Any, List, Dict + + +class ShareLinks: + def __init__(self, admin: str, viewer: str): + self.admin = admin + self.viewer = viewer + + +class Permissions: + def __init__( + self, + can_read: bool, + can_update: bool, + can_destroy: bool, + can_leave: bool + ): + self.can_read = can_read + self.can_update = can_update + self.can_destroy = can_destroy + self.can_leave = can_leave + + +class Project: + def __init__( + self, + id: str, + name: str, + share_links: ShareLinks, + inboxes: List[Dict[str, Any]], + permissions: Permissions + ): + self.id = id + self.name = name + self.share_links = share_links + self.inboxes = inboxes + self.permissions = permissions + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Project": + share_links = ShareLinks(**data["share_links"]) + permissions = Permissions(**data["permissions"]) + inboxes = data.get("inboxes", []) + return cls( + id=data["id"], + name=data["name"], + share_links=share_links, + inboxes=inboxes, + permissions=permissions, + ) diff --git a/tests/unit/api/__init__.py b/tests/unit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py new file mode 100644 index 0000000..041da4d --- /dev/null +++ b/tests/unit/api/test_projects.py @@ -0,0 +1,194 @@ +import pytest +from requests import Session +import responses +from typing import Any, Dict + +from mailtrap.api.projects import ProjectsApiClient +from mailtrap.constants import MAILTRAP_HOST +from mailtrap.exceptions import APIError +from mailtrap.models.common import DeletedObject +from mailtrap.models.projects import Project + +ACCOUNT_ID = "321" +PROJECT_ID = "123" +BASE_URL = f"https://{MAILTRAP_HOST}/api/accounts/{ACCOUNT_ID}/projects" + + +@pytest.fixture +def client() -> ProjectsApiClient: + return ProjectsApiClient(Session()) + + +@pytest.fixture +def sample_project_dict() -> Dict[str, Any]: + return { + "id": PROJECT_ID, + "name": "Test Project", + "inboxes": [], + "share_links": { + "admin": "https://mailtrap.io/projects/321/admin", + "viewer": "https://mailtrap.io/projects/321/viewer" + }, + "permissions": { + "can_read": True, + "can_update": True, + "can_destroy": True, + "can_leave": True + } + } + +class TestProjectsApi: + @responses.activate + def test_get_list_should_return_project_list( + self, + client: ProjectsApiClient, + sample_project_dict: Dict + ) -> None: + responses.add( + responses.GET, + BASE_URL, + json=[sample_project_dict], + status=200, + ) + + projects = client.get_list(ACCOUNT_ID) + + assert isinstance(projects, list) + assert all(isinstance(p, Project) for p in projects) + assert projects[0].id == PROJECT_ID + + + @responses.activate + def test_get_by_id_should_raise_not_found_error( + self, + client: ProjectsApiClient + ) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + responses.add( + responses.GET, + url, + status=404, + json={"error": "Not Found"}, + ) + + with pytest.raises(APIError) as exc_info: + client.get_by_id(ACCOUNT_ID, PROJECT_ID) + + assert "Not Found" in str(exc_info) + + + @responses.activate + def test_get_by_id_should_return_single_project( + self, + client: ProjectsApiClient, + sample_project_dict: Dict + ) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + responses.add( + responses.GET, + url, + json=sample_project_dict, + status=200, + ) + + project = client.get_by_id(ACCOUNT_ID, PROJECT_ID) + + assert isinstance(project, Project) + assert project.id == PROJECT_ID + + + @responses.activate + def test_create_should_return_new_project( + self, + client: ProjectsApiClient, + sample_project_dict: Dict + ) -> None: + responses.add( + responses.POST, + BASE_URL, + json=sample_project_dict, + status=201, + ) + + project = client.create(ACCOUNT_ID, name="New Project") + + assert isinstance(project, Project) + assert project.name == "Test Project" + + + @responses.activate + def test_update_should_raise_not_found_error(self, client: ProjectsApiClient) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + responses.add( + responses.PATCH, + url, + status=404, + json={"error": "Not Found"}, + ) + + with pytest.raises(APIError) as exc_info: + client.update(ACCOUNT_ID, PROJECT_ID, "Update Project Name") + + assert "Not Found" in str(exc_info) + + + @responses.activate + def test_update_should_return_updated_project( + self, + client: ProjectsApiClient, + sample_project_dict: Dict + ) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + updated_name = "Updated Project" + updated_project_dict = sample_project_dict.copy() + updated_project_dict["name"] = updated_name + + responses.add( + responses.PATCH, + url, + json=updated_project_dict, + status=200, + ) + + project = client.update(ACCOUNT_ID, PROJECT_ID, name=updated_name) + + assert isinstance(project, Project) + assert project.name == updated_name + + + @responses.activate + def test_delete_should_raise_not_found_error( + self, + client: ProjectsApiClient + ) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + responses.add( + responses.DELETE, + url, + status=404, + json={"error": "Not Found"}, + ) + + with pytest.raises(APIError) as exc_info: + client.delete(ACCOUNT_ID, PROJECT_ID) + + assert "Not Found" in str(exc_info) + + + @responses.activate + def test_delete_should_return_deleted_object( + self, + client: ProjectsApiClient + ) -> None: + url = f"{BASE_URL}/{PROJECT_ID}" + responses.add( + responses.DELETE, + url, + json={"id": PROJECT_ID}, + status=200, + ) + + result = client.delete(ACCOUNT_ID, PROJECT_ID) + + assert isinstance(result, DeletedObject) + assert result.id == PROJECT_ID From 994cacf1133276bdc8493e55741ce2a92fdff9c1 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 28 Jul 2025 13:37:01 +0300 Subject: [PATCH 02/13] Fix typo for DELETE http method --- mailtrap/api/base.py | 2 +- mailtrap/api/projects.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mailtrap/api/base.py b/mailtrap/api/base.py index 3ad6837..f9a564a 100644 --- a/mailtrap/api/base.py +++ b/mailtrap/api/base.py @@ -14,7 +14,7 @@ class HttpMethod(Enum): POST = "POST" PUT = "PUT" PATCH = "PATCH" - DELTE = "DELETE" + DELETE = "DELETE" def _extract_errors(data: Dict[str, Any]) -> List[str]: diff --git a/mailtrap/api/projects.py b/mailtrap/api/projects.py index 5efa3e9..24295a2 100644 --- a/mailtrap/api/projects.py +++ b/mailtrap/api/projects.py @@ -44,7 +44,7 @@ def update(self, account_id: str, project_id: str, name: str) -> Project: def delete(self, account_id: str, project_id: str) -> DeletedObject: response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( - HttpMethod.DELTE, + HttpMethod.DELETE, self._build_url(account_id, project_id), )) return DeletedObject(response["id"]) From 3723d3554467468ad9f002678d9768dd91ca4736 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Sat, 2 Aug 2025 02:49:36 +0300 Subject: [PATCH 03/13] Fix issue #29. Implement new version of HttpClient and use it for Projects API --- examples/testing/projects.py | 30 +++++++ mailtrap/api/__init__.py | 0 mailtrap/api/base.py | 71 ---------------- mailtrap/api/projects.py | 50 ------------ mailtrap/api/resources/__init__.py | 0 mailtrap/api/resources/projects.py | 37 +++++++++ mailtrap/api/testing.py | 13 +++ mailtrap/client.py | 21 +++-- mailtrap/config.py | 3 + mailtrap/constants.py | 1 - mailtrap/http.py | 127 +++++++++++++++++++++++++++++ mailtrap/models/__init__.py | 0 mailtrap/models/base.py | 42 ++++++++++ mailtrap/models/common.py | 3 - mailtrap/models/inboxes.py | 31 +++++++ mailtrap/models/permissions.py | 11 +++ mailtrap/models/projects.py | 60 ++++---------- tests/unit/api/test_projects.py | 114 +++++++++++--------------- 18 files changed, 369 insertions(+), 245 deletions(-) create mode 100644 examples/testing/projects.py create mode 100644 mailtrap/api/__init__.py delete mode 100644 mailtrap/api/base.py delete mode 100644 mailtrap/api/projects.py create mode 100644 mailtrap/api/resources/__init__.py create mode 100644 mailtrap/api/resources/projects.py create mode 100644 mailtrap/api/testing.py create mode 100644 mailtrap/config.py delete mode 100644 mailtrap/constants.py create mode 100644 mailtrap/http.py create mode 100644 mailtrap/models/__init__.py create mode 100644 mailtrap/models/base.py delete mode 100644 mailtrap/models/common.py create mode 100644 mailtrap/models/inboxes.py create mode 100644 mailtrap/models/permissions.py diff --git a/examples/testing/projects.py b/examples/testing/projects.py new file mode 100644 index 0000000..da37188 --- /dev/null +++ b/examples/testing/projects.py @@ -0,0 +1,30 @@ +import os +import sys +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from typing import Optional +from mailtrap import MailtrapClient +from mailtrap.models.projects import Project + +API_TOKEN = "YOU_API_TOKEN" +ACCOUNT_ID = "YOU_ACCOUNT_ID" + + +def find_project_by_name(project_name: str, projects: list[Project]) -> Optional[str]: + filtered_projects = [project for project in projects if project.name == project_name] + if filtered_projects: + return filtered_projects[0].id + return None + + +client = MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +api = client.testing.projects + +project_name = "Example-project" + +created_project = api.create(project_name=project_name) +projects = api.get_list() +project_id = find_project_by_name(project_name, projects) +project = api.get_by_id(project_id) +updated_projected = api.update(project_id, "Updated-project-name") +api.delete(project_id) diff --git a/mailtrap/api/__init__.py b/mailtrap/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/api/base.py b/mailtrap/api/base.py deleted file mode 100644 index f9a564a..0000000 --- a/mailtrap/api/base.py +++ /dev/null @@ -1,71 +0,0 @@ -from abc import ABC -from enum import Enum -from typing import Any, Dict, List, NoReturn, Union, cast -from requests import Response, Session - -from mailtrap.exceptions import APIError, AuthorizationError - -RESPONSE_TYPE = Dict[str, Any] -LIST_RESPONSE_TYPE = List[Dict[str, Any]] - - -class HttpMethod(Enum): - GET = "GET" - POST = "POST" - PUT = "PUT" - PATCH = "PATCH" - DELETE = "DELETE" - - -def _extract_errors(data: Dict[str, Any]) -> List[str]: - if "errors" in data: - errors = data["errors"] - - if isinstance(errors, list): - return [str(err) for err in errors] - - if isinstance(errors, dict): - flat_errors = [] - for key, value in errors.items(): - if isinstance(value, list): - flat_errors.extend([f"{key}: {v}" for v in value]) - else: - flat_errors.append(f"{key}: {value}") - return flat_errors - - return [str(errors)] - - elif "error" in data: - return [str(data["error"])] - - return ["Unknown error"] - - -class BaseHttpApiClient(ABC): - def __init__(self, session: Session): - self.session = session - - def _request(self, method: HttpMethod, url: str, **kwargs: Any) -> Union[RESPONSE_TYPE, LIST_RESPONSE_TYPE]: - response = self.session.request(method.value, url, **kwargs) - if response.ok: - data = cast(Union[RESPONSE_TYPE, LIST_RESPONSE_TYPE], response.json()) - return data - - self._handle_failed_response(response) - - @staticmethod - def _handle_failed_response(response: Response) -> NoReturn: - status_code = response.status_code - - try: - data = response.json() - except ValueError: - raise APIError(status_code, errors=["Unknown Error"]) - - errors = _extract_errors(data) - - if status_code == 401: - raise AuthorizationError(errors=errors) - - raise APIError(status_code, errors=errors) - diff --git a/mailtrap/api/projects.py b/mailtrap/api/projects.py deleted file mode 100644 index 24295a2..0000000 --- a/mailtrap/api/projects.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import List, cast - -from mailtrap.api.base import LIST_RESPONSE_TYPE, RESPONSE_TYPE, BaseHttpApiClient, HttpMethod -from mailtrap.constants import MAILTRAP_HOST -from mailtrap.models.common import DeletedObject -from mailtrap.models.projects import Project - - -class ProjectsApiClient(BaseHttpApiClient): - - def _build_url(self, account_id: str, *parts: str) -> str: - base_url = f"https://{MAILTRAP_HOST}/api/accounts/{account_id}/projects" - return "/".join([base_url, *parts]) - - def get_list(self, account_id: str) -> List[Project]: - response: LIST_RESPONSE_TYPE = cast(LIST_RESPONSE_TYPE, self._request( - HttpMethod.GET, - self._build_url(account_id) - )) - return [Project.from_dict(proj) for proj in response] - - def get_by_id(self, account_id: str, project_id: str) -> Project: - response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( - HttpMethod.GET, - self._build_url(account_id, project_id) - )) - return Project.from_dict(response) - - def create(self, account_id: str, name: str) -> Project: - response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( - HttpMethod.POST, - self._build_url(account_id), - json={"project": {"name": name}}, - )) - return Project.from_dict(response) - - def update(self, account_id: str, project_id: str, name: str) -> Project: - response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( - HttpMethod.PATCH, - self._build_url(account_id, project_id), - json={"project": {"name": name}}, - )) - return Project.from_dict(response) - - def delete(self, account_id: str, project_id: str) -> DeletedObject: - response: RESPONSE_TYPE = cast(RESPONSE_TYPE, self._request( - HttpMethod.DELETE, - self._build_url(account_id, project_id), - )) - return DeletedObject(response["id"]) diff --git a/mailtrap/api/resources/__init__.py b/mailtrap/api/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py new file mode 100644 index 0000000..d6afc79 --- /dev/null +++ b/mailtrap/api/resources/projects.py @@ -0,0 +1,37 @@ +from mailtrap.http import HttpClient +from mailtrap.models.base import DeletedObject +from mailtrap.models.projects import Project + + +class ProjectsApi: + def __init__(self, account_id: str, client: HttpClient) -> None: + self.account_id = account_id + self.client = client + + def get_list(self) -> list[Project]: + response = self.client.list(f"/api/accounts/{self.account_id}/projects") + return [Project.from_dict(project) for project in response] + + def get_by_id(self, project_id: str) -> Project: + response = self.client.get(f"/api/accounts/{self.account_id}/projects/{project_id}") + return Project.from_dict(response) + + def create(self, project_name: str) -> Project: + response = self.client.post( + f"/api/accounts/{self.account_id}/projects", + json={"project": {"name": project_name}}, + ) + return Project.from_dict(response) + + def update(self, project_id: str, project_name: str) -> Project: + response = self.client.patch( + f"/api/accounts/{self.account_id}/projects/{project_id}", + json={"project": {"name": project_name}}, + ) + return Project.from_dict(response) + + def delete(self, project_id: str) -> DeletedObject: + response = self.client.delete( + f"/api/accounts/{self.account_id}/projects/{project_id}", + ) + return DeletedObject(response["id"]) diff --git a/mailtrap/api/testing.py b/mailtrap/api/testing.py new file mode 100644 index 0000000..fbd5d6c --- /dev/null +++ b/mailtrap/api/testing.py @@ -0,0 +1,13 @@ + +from mailtrap.api.resources.projects import ProjectsApi +from mailtrap.http import HttpClient + + +class TestingApi: + def __init__(self, account_id: str, client: HttpClient) -> None: + self.account_id = account_id + self.client = client + + @property + def projects(self) -> ProjectsApi: + return ProjectsApi(account_id=self.account_id, client=self.client) diff --git a/mailtrap/client.py b/mailtrap/client.py index 834f5cc..b3b0e69 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -4,10 +4,12 @@ import requests -from mailtrap.api.projects import ProjectsApiClient +from mailtrap.api.testing import TestingApi +from mailtrap.config import MAILTRAP_HOST from mailtrap.exceptions import APIError from mailtrap.exceptions import AuthorizationError from mailtrap.exceptions import ClientConfigurationError +from mailtrap.http import HttpClient from mailtrap.mail.base import BaseMail @@ -25,6 +27,7 @@ def __init__( bulk: bool = False, sandbox: bool = False, inbox_id: Optional[str] = None, + account_id: Optional[str] = None, ) -> None: self.token = token self.api_host = api_host @@ -32,18 +35,22 @@ def __init__( self.bulk = bulk self.sandbox = sandbox self.inbox_id = inbox_id + self.account_id = account_id self._validate_itself() - self._http_client = requests.Session() - self._http_client.headers.update(self.headers) - @property - def projects_api(self) -> ProjectsApiClient: - return ProjectsApiClient(self._http_client) + def testing(self) -> TestingApi: + if not self.account_id: + raise ClientConfigurationError("`account_id` is required for Testing API") + + http_client = HttpClient(host=MAILTRAP_HOST, headers=self.headers) + return TestingApi(account_id=self.account_id, client=http_client) def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: - response = self._http_client.post(self.api_send_url, json=mail.api_data) + response = requests.post( + self.api_send_url, headers=self.headers, json=mail.api_data + ) if response.ok: data: dict[str, Union[bool, list[str]]] = response.json() diff --git a/mailtrap/config.py b/mailtrap/config.py new file mode 100644 index 0000000..49b341a --- /dev/null +++ b/mailtrap/config.py @@ -0,0 +1,3 @@ +MAILTRAP_HOST = "mailtrap.io" + +DEFAULT_REQUEST_TIMEOUT = 30 # in seconds diff --git a/mailtrap/constants.py b/mailtrap/constants.py deleted file mode 100644 index 58d4257..0000000 --- a/mailtrap/constants.py +++ /dev/null @@ -1 +0,0 @@ -MAILTRAP_HOST = "mailtrap.io" diff --git a/mailtrap/http.py b/mailtrap/http.py new file mode 100644 index 0000000..e40e8d9 --- /dev/null +++ b/mailtrap/http.py @@ -0,0 +1,127 @@ +from typing import Any, Optional, Type, TypeVar +from typing import NoReturn + +from requests import Response, Session + +from mailtrap.config import DEFAULT_REQUEST_TIMEOUT +from mailtrap.exceptions import APIError +from mailtrap.exceptions import AuthorizationError + +T = TypeVar("T") + + +class HttpClient: + def __init__( + self, + host: str, + headers: Optional[dict[str, str]] = None, + timeout: int = DEFAULT_REQUEST_TIMEOUT + ): + self._host = host + self._session = Session() + self._session.headers.update(headers or {}) + self._timeout = timeout + + def _url(self, path: str) -> str: + return f"https://{self._host}/{path.lstrip('/')}" + + def _handle_failed_response(self, response: Response) -> NoReturn: + status_code = response.status_code + try: + data = response.json() + except ValueError: + raise APIError(status_code, errors=["Unknown Error"]) + + errors = _extract_errors(data) + + if status_code == 401: + raise AuthorizationError(errors=errors) + + raise APIError(status_code, errors=errors) + + def _process_response( + self, + response: Response, + expected_type: Type[T] + ) -> T: + if not response.ok: + self._handle_failed_response(response) + data = response.json() + if not isinstance(data, expected_type): + raise APIError(response.status_code, errors=[f"Expected response type {expected_type.__name__}"]) + return data + + def _process_response_dict(self, response: Response) -> dict[str, Any]: + return self._process_response(response, dict) + + def _process_response_list(self, response: Response) -> list[dict[str, Any]]: + return self._process_response(response, list) + + def get( + self, + path: str, + params: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + response = self._session.get(self._url(path), params=params, timeout=self._timeout) + return self._process_response_dict(response) + + def list( + self, + path: str, + params: Optional[dict[str, Any]] = None + ) -> list[dict[str, Any]]: + response = self._session.get(self._url(path), params=params, timeout=self._timeout) + return self._process_response_list(response) + + def post( + self, + path: str, + json: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + response = self._session.post(self._url(path), json=json, timeout=self._timeout) + return self._process_response_dict(response) + + def put( + self, + path: str, + json: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + response = self._session.put(self._url(path), json=json, timeout=self._timeout) + return self._process_response_dict(response) + + def patch( + self, + path: str, + json: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + response = self._session.patch(self._url(path), json=json, timeout=self._timeout) + return self._process_response_dict(response) + + def delete(self, path: str) -> dict[str, Any]: + response = self._session.delete(self._url(path), timeout=self._timeout) + return self._process_response_dict(response) + + +def _extract_errors(data: dict[str, Any]) -> list[str]: + def flatten_errors(errors: Any) -> list[str]: + if isinstance(errors, list): + return [str(error) for error in errors] + + if isinstance(errors, dict): + flat_errors = [] + for key, value in errors.items(): + if isinstance(value, list): + flat_errors.extend([f"{key}: {v}" for v in value]) + else: + flat_errors.append(f"{key}: {value}") + return flat_errors + + return [str(errors)] + + if "errors" in data: + return flatten_errors(data["errors"]) + + if "error" in data: + return flatten_errors(data["error"]) + + return ["Unknown error"] diff --git a/mailtrap/models/__init__.py b/mailtrap/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mailtrap/models/base.py b/mailtrap/models/base.py new file mode 100644 index 0000000..16b220f --- /dev/null +++ b/mailtrap/models/base.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, fields, is_dataclass +from typing import Any, Type, TypeVar, get_args, get_origin + + +T = TypeVar("T", bound="BaseModel") + + +@dataclass +class BaseModel: + @classmethod + def from_dict(cls: Type[T], data: dict[str, Any]) -> T: + values: dict[str, Any] = {} + for field in fields(cls): + value = data.get(field.name) + + if value is None: + values[field.name] = None + continue + + field_type = field.type + values[field.name] = cls._parse_value(value, field_type) + + return cls(**values) + + @classmethod + def _parse_value(cls, value: Any, field_type: Any) -> Any: + origin = get_origin(field_type) + + if origin is list: + item_type = get_args(field_type)[0] + return [cls._parse_value(item, item_type) for item in value] + + if is_dataclass(field_type) and hasattr(field_type, "from_dict"): + return field_type.from_dict(value) + + return value + + + +@dataclass +class DeletedObject(BaseModel): + id: str diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py deleted file mode 100644 index 7643b12..0000000 --- a/mailtrap/models/common.py +++ /dev/null @@ -1,3 +0,0 @@ -class DeletedObject: - def __init__(self, id: str): - self.id = id diff --git a/mailtrap/models/inboxes.py b/mailtrap/models/inboxes.py new file mode 100644 index 0000000..47d82fd --- /dev/null +++ b/mailtrap/models/inboxes.py @@ -0,0 +1,31 @@ + +from dataclasses import dataclass +from mailtrap.models.base import BaseModel +from mailtrap.models.permissions import Permissions + + +@dataclass +class Inbox(BaseModel): + id: int + name: str + username: str + max_size: int + status: str + email_username: str + email_username_enabled: bool + sent_messages_count: int + forwarded_messages_count: int + used: bool + forward_from_email_address: str + project_id: int + domain: str + pop3_domain: str + email_domain: str + emails_count: int + emails_unread_count: int + smtp_ports: list[int] + pop3_ports: list[int] + max_message_size: int + permissions: Permissions + password: str | None = None # Password is only available if you have admin permissions for the inbox. + last_message_sent_at: str | None = None diff --git a/mailtrap/models/permissions.py b/mailtrap/models/permissions.py new file mode 100644 index 0000000..dd23a53 --- /dev/null +++ b/mailtrap/models/permissions.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from mailtrap.models.base import BaseModel + + +@dataclass +class Permissions(BaseModel): + can_read: bool + can_update: bool + can_destroy: bool + can_leave: bool diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py index 2371875..6e80283 100644 --- a/mailtrap/models/projects.py +++ b/mailtrap/models/projects.py @@ -1,50 +1,20 @@ -from typing import Any, List, Dict +from dataclasses import dataclass +from mailtrap.models.base import BaseModel +from mailtrap.models.inboxes import Inbox +from mailtrap.models.permissions import Permissions -class ShareLinks: - def __init__(self, admin: str, viewer: str): - self.admin = admin - self.viewer = viewer +@dataclass +class ShareLinks(BaseModel): + admin: str + viewer: str -class Permissions: - def __init__( - self, - can_read: bool, - can_update: bool, - can_destroy: bool, - can_leave: bool - ): - self.can_read = can_read - self.can_update = can_update - self.can_destroy = can_destroy - self.can_leave = can_leave - -class Project: - def __init__( - self, - id: str, - name: str, - share_links: ShareLinks, - inboxes: List[Dict[str, Any]], - permissions: Permissions - ): - self.id = id - self.name = name - self.share_links = share_links - self.inboxes = inboxes - self.permissions = permissions - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "Project": - share_links = ShareLinks(**data["share_links"]) - permissions = Permissions(**data["permissions"]) - inboxes = data.get("inboxes", []) - return cls( - id=data["id"], - name=data["name"], - share_links=share_links, - inboxes=inboxes, - permissions=permissions, - ) +@dataclass +class Project(BaseModel): + id: str + name: str + share_links: ShareLinks + inboxes: list[Inbox] + permissions: Permissions diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index 041da4d..9239baa 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -1,69 +1,65 @@ +from typing import Any + import pytest -from requests import Session import responses -from typing import Any, Dict -from mailtrap.api.projects import ProjectsApiClient -from mailtrap.constants import MAILTRAP_HOST +from mailtrap.api.resources.projects import ProjectsApi +from mailtrap.config import MAILTRAP_HOST from mailtrap.exceptions import APIError -from mailtrap.models.common import DeletedObject +from mailtrap.http import HttpClient +from mailtrap.models.base import DeletedObject from mailtrap.models.projects import Project ACCOUNT_ID = "321" PROJECT_ID = "123" -BASE_URL = f"https://{MAILTRAP_HOST}/api/accounts/{ACCOUNT_ID}/projects" +BASE_PROJECTS_URL = f"https://{MAILTRAP_HOST}/api/accounts/{ACCOUNT_ID}/projects" @pytest.fixture -def client() -> ProjectsApiClient: - return ProjectsApiClient(Session()) +def client() -> ProjectsApi: + return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(MAILTRAP_HOST)) @pytest.fixture -def sample_project_dict() -> Dict[str, Any]: +def sample_project_dict() -> dict[str, Any]: return { - "id": PROJECT_ID, - "name": "Test Project", + "id": PROJECT_ID, + "name": "Test Project", "inboxes": [], "share_links": { "admin": "https://mailtrap.io/projects/321/admin", - "viewer": "https://mailtrap.io/projects/321/viewer" + "viewer": "https://mailtrap.io/projects/321/viewer", }, "permissions": { - "can_read": True, - "can_update": True, - "can_destroy": True, - "can_leave": True - } + "can_read": True, + "can_update": True, + "can_destroy": True, + "can_leave": True, + }, } + class TestProjectsApi: @responses.activate def test_get_list_should_return_project_list( - self, - client: ProjectsApiClient, - sample_project_dict: Dict + self, client: ProjectsApi, sample_project_dict: dict ) -> None: responses.add( responses.GET, - BASE_URL, + BASE_PROJECTS_URL, json=[sample_project_dict], status=200, ) - projects = client.get_list(ACCOUNT_ID) + projects = client.get_list() assert isinstance(projects, list) assert all(isinstance(p, Project) for p in projects) assert projects[0].id == PROJECT_ID - @responses.activate - def test_get_by_id_should_raise_not_found_error( - self, - client: ProjectsApiClient - ) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + def test_get_by_id_should_raise_not_found_error(self, client: ProjectsApi) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.GET, url, @@ -72,18 +68,15 @@ def test_get_by_id_should_raise_not_found_error( ) with pytest.raises(APIError) as exc_info: - client.get_by_id(ACCOUNT_ID, PROJECT_ID) - - assert "Not Found" in str(exc_info) + client.get_by_id(PROJECT_ID) + assert "Not Found" in str(exc_info) @responses.activate def test_get_by_id_should_return_single_project( - self, - client: ProjectsApiClient, - sample_project_dict: Dict + self, client: ProjectsApi, sample_project_dict: dict ) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.GET, url, @@ -91,34 +84,30 @@ def test_get_by_id_should_return_single_project( status=200, ) - project = client.get_by_id(ACCOUNT_ID, PROJECT_ID) + project = client.get_by_id(PROJECT_ID) assert isinstance(project, Project) assert project.id == PROJECT_ID - @responses.activate def test_create_should_return_new_project( - self, - client: ProjectsApiClient, - sample_project_dict: Dict + self, client: ProjectsApi, sample_project_dict: dict ) -> None: responses.add( responses.POST, - BASE_URL, + BASE_PROJECTS_URL, json=sample_project_dict, status=201, ) - project = client.create(ACCOUNT_ID, name="New Project") + project = client.create(project_name="New Project") assert isinstance(project, Project) assert project.name == "Test Project" - @responses.activate - def test_update_should_raise_not_found_error(self, client: ProjectsApiClient) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + def test_update_should_raise_not_found_error(self, client: ProjectsApi) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.PATCH, url, @@ -127,18 +116,15 @@ def test_update_should_raise_not_found_error(self, client: ProjectsApiClient) -> ) with pytest.raises(APIError) as exc_info: - client.update(ACCOUNT_ID, PROJECT_ID, "Update Project Name") - - assert "Not Found" in str(exc_info) + client.update(PROJECT_ID, project_name="Update Project Name") + assert "Not Found" in str(exc_info) @responses.activate def test_update_should_return_updated_project( - self, - client: ProjectsApiClient, - sample_project_dict: Dict + self, client: ProjectsApi, sample_project_dict: dict ) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" updated_name = "Updated Project" updated_project_dict = sample_project_dict.copy() updated_project_dict["name"] = updated_name @@ -150,18 +136,14 @@ def test_update_should_return_updated_project( status=200, ) - project = client.update(ACCOUNT_ID, PROJECT_ID, name=updated_name) + project = client.update(PROJECT_ID, project_name=updated_name) assert isinstance(project, Project) assert project.name == updated_name - @responses.activate - def test_delete_should_raise_not_found_error( - self, - client: ProjectsApiClient - ) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + def test_delete_should_raise_not_found_error(self, client: ProjectsApi) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.DELETE, url, @@ -170,17 +152,13 @@ def test_delete_should_raise_not_found_error( ) with pytest.raises(APIError) as exc_info: - client.delete(ACCOUNT_ID, PROJECT_ID) - - assert "Not Found" in str(exc_info) + client.delete(PROJECT_ID) + assert "Not Found" in str(exc_info) @responses.activate - def test_delete_should_return_deleted_object( - self, - client: ProjectsApiClient - ) -> None: - url = f"{BASE_URL}/{PROJECT_ID}" + def test_delete_should_return_deleted_object(self, client: ProjectsApi) -> None: + url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.DELETE, url, @@ -188,7 +166,7 @@ def test_delete_should_return_deleted_object( status=200, ) - result = client.delete(ACCOUNT_ID, PROJECT_ID) + result = client.delete(PROJECT_ID) assert isinstance(result, DeletedObject) assert result.id == PROJECT_ID From 26d8c577a2e1505a87fac112520c4f7d650a628b Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 4 Aug 2025 19:10:52 +0300 Subject: [PATCH 04/13] Fixx issue #29. Start using pydantic models instead of dataclasses --- .gitignore | 3 + README.md | 4 +- examples/testing/projects.py | 22 +++---- mailtrap/api/resources/projects.py | 26 ++++---- mailtrap/api/testing.py | 8 ++- mailtrap/client.py | 35 ++++++---- mailtrap/http.py | 72 +++++++++------------ mailtrap/models/base.py | 42 ------------ mailtrap/models/projects.py | 20 ------ mailtrap/{models => schemas}/__init__.py | 0 mailtrap/schemas/base.py | 5 ++ mailtrap/{models => schemas}/inboxes.py | 11 ++-- mailtrap/{models => schemas}/permissions.py | 5 +- mailtrap/schemas/projects.py | 16 +++++ requirements.test.txt | 2 + requirements.txt | 1 + tests/unit/api/test_projects.py | 6 +- tests/unit/test_client.py | 7 +- 18 files changed, 123 insertions(+), 162 deletions(-) delete mode 100644 mailtrap/models/base.py delete mode 100644 mailtrap/models/projects.py rename mailtrap/{models => schemas}/__init__.py (100%) create mode 100644 mailtrap/schemas/base.py rename mailtrap/{models => schemas}/inboxes.py (63%) rename mailtrap/{models => schemas}/permissions.py (56%) create mode 100644 mailtrap/schemas/projects.py diff --git a/.gitignore b/.gitignore index aade00b..a0926f2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ dist/ # IntelliJ's project specific settings .idea/ +# VSCode's project specific settings +.vscode/ + # mypy .mypy_cache/ diff --git a/README.md b/README.md index c27c5d0..6e56736 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Versions of this package up to 1.0.1 were a different, unrelated project, that i ### Prerequisites -- Python version 3.6+ +- Python version 3.9+ ### Install package @@ -150,7 +150,7 @@ To setup virtual environments, run tests and linters use: tox ``` -It will create virtual environments with all installed dependencies for each available python interpreter (starting from `python3.6`) on your machine. +It will create virtual environments with all installed dependencies for each available python interpreter (starting from `python3.9`) on your machine. By default, they will be available in `{project}/.tox/` directory. So, for instance, to activate `python3.11` environment, run the following: ```bash diff --git a/examples/testing/projects.py b/examples/testing/projects.py index da37188..f4ff682 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -1,10 +1,7 @@ -import os -import sys -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) - from typing import Optional + from mailtrap import MailtrapClient -from mailtrap.models.projects import Project +from mailtrap.schemas.projects import Project API_TOKEN = "YOU_API_TOKEN" ACCOUNT_ID = "YOU_ACCOUNT_ID" @@ -17,14 +14,15 @@ def find_project_by_name(project_name: str, projects: list[Project]) -> Optional return None -client = MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) -api = client.testing.projects +MailtrapClient.configure_access_token(API_TOKEN) +testing_api = MailtrapClient.get_testing_api(ACCOUNT_ID) +projects_api = testing_api.projects project_name = "Example-project" -created_project = api.create(project_name=project_name) -projects = api.get_list() +created_project = projects_api.create(project_name=project_name) +projects = projects_api.get_list() project_id = find_project_by_name(project_name, projects) -project = api.get_by_id(project_id) -updated_projected = api.update(project_id, "Updated-project-name") -api.delete(project_id) +project = projects_api.get_by_id(project_id) +updated_projected = projects_api.update(project_id, "Updated-project-name") +deleted_object = projects_api.delete(project_id) diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py index d6afc79..108d18b 100644 --- a/mailtrap/api/resources/projects.py +++ b/mailtrap/api/resources/projects.py @@ -1,37 +1,39 @@ from mailtrap.http import HttpClient -from mailtrap.models.base import DeletedObject -from mailtrap.models.projects import Project +from mailtrap.schemas.base import DeletedObject +from mailtrap.schemas.projects import Project class ProjectsApi: - def __init__(self, account_id: str, client: HttpClient) -> None: + def __init__(self, client: HttpClient, account_id: str) -> None: self.account_id = account_id self.client = client def get_list(self) -> list[Project]: response = self.client.list(f"/api/accounts/{self.account_id}/projects") - return [Project.from_dict(project) for project in response] + return [Project(**project) for project in response] - def get_by_id(self, project_id: str) -> Project: - response = self.client.get(f"/api/accounts/{self.account_id}/projects/{project_id}") - return Project.from_dict(response) + def get_by_id(self, project_id: int) -> Project: + response = self.client.get( + f"/api/accounts/{self.account_id}/projects/{project_id}" + ) + return Project(**response) def create(self, project_name: str) -> Project: response = self.client.post( f"/api/accounts/{self.account_id}/projects", json={"project": {"name": project_name}}, ) - return Project.from_dict(response) + return Project(**response) - def update(self, project_id: str, project_name: str) -> Project: + def update(self, project_id: int, project_name: str) -> Project: response = self.client.patch( f"/api/accounts/{self.account_id}/projects/{project_id}", json={"project": {"name": project_name}}, ) - return Project.from_dict(response) + return Project(**response) - def delete(self, project_id: str) -> DeletedObject: + def delete(self, project_id: int) -> DeletedObject: response = self.client.delete( f"/api/accounts/{self.account_id}/projects/{project_id}", ) - return DeletedObject(response["id"]) + return DeletedObject(**response) diff --git a/mailtrap/api/testing.py b/mailtrap/api/testing.py index fbd5d6c..a8ca3e3 100644 --- a/mailtrap/api/testing.py +++ b/mailtrap/api/testing.py @@ -1,11 +1,15 @@ +from typing import Optional -from mailtrap.api.resources.projects import ProjectsApi +from mailtrap.api.resources.projects import ProjectsApi from mailtrap.http import HttpClient class TestingApi: - def __init__(self, account_id: str, client: HttpClient) -> None: + def __init__( + self, client: HttpClient, account_id: str, inbox_id: Optional[str] = None + ) -> None: self.account_id = account_id + self.inbox_id = inbox_id self.client = client @property diff --git a/mailtrap/client.py b/mailtrap/client.py index b3b0e69..18c5c7b 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -19,37 +19,38 @@ class MailtrapClient: BULK_HOST = "bulk.api.mailtrap.io" SANDBOX_HOST = "sandbox.api.mailtrap.io" + _default_token: Optional[str] = None + def __init__( self, - token: str, api_host: Optional[str] = None, api_port: int = DEFAULT_PORT, bulk: bool = False, sandbox: bool = False, inbox_id: Optional[str] = None, - account_id: Optional[str] = None, ) -> None: - self.token = token self.api_host = api_host self.api_port = api_port self.bulk = bulk self.sandbox = sandbox self.inbox_id = inbox_id - self.account_id = account_id self._validate_itself() - @property - def testing(self) -> TestingApi: - if not self.account_id: - raise ClientConfigurationError("`account_id` is required for Testing API") + @classmethod + def configure_access_token(cls, token: str) -> None: + cls._default_token = token - http_client = HttpClient(host=MAILTRAP_HOST, headers=self.headers) - return TestingApi(account_id=self.account_id, client=http_client) + @classmethod + def get_testing_api( + cls, account_id: str, inbox_id: Optional[str] = None + ) -> TestingApi: + http_client = HttpClient(host=MAILTRAP_HOST, headers=cls.get_default_headers()) + return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client) def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: response = requests.post( - self.api_send_url, headers=self.headers, json=mail.api_data + self.api_send_url, headers=self.get_default_headers(), json=mail.api_data ) if response.ok: @@ -70,10 +71,16 @@ def api_send_url(self) -> str: return url - @property - def headers(self) -> dict[str, str]: + @classmethod + def get_default_headers(cls) -> dict[str, str]: + if cls._default_token is None: + raise ValueError( + "Access token is not configured. " + "Call MailtrapClient.configure_token(...) first." + ) + return { - "Authorization": f"Bearer {self.token}", + "Authorization": f"Bearer {cls._default_token}", "Content-Type": "application/json", "User-Agent": ( "mailtrap-python (https://github.com/railsware/mailtrap-python)" diff --git a/mailtrap/http.py b/mailtrap/http.py index e40e8d9..6898d8e 100644 --- a/mailtrap/http.py +++ b/mailtrap/http.py @@ -1,7 +1,10 @@ -from typing import Any, Optional, Type, TypeVar +from typing import Any from typing import NoReturn +from typing import Optional +from typing import TypeVar -from requests import Response, Session +from requests import Response +from requests import Session from mailtrap.config import DEFAULT_REQUEST_TIMEOUT from mailtrap.exceptions import APIError @@ -12,10 +15,10 @@ class HttpClient: def __init__( - self, - host: str, - headers: Optional[dict[str, str]] = None, - timeout: int = DEFAULT_REQUEST_TIMEOUT + self, + host: str, + headers: Optional[dict[str, str]] = None, + timeout: int = DEFAULT_REQUEST_TIMEOUT, ): self._host = host self._session = Session() @@ -39,16 +42,15 @@ def _handle_failed_response(self, response: Response) -> NoReturn: raise APIError(status_code, errors=errors) - def _process_response( - self, - response: Response, - expected_type: Type[T] - ) -> T: + def _process_response(self, response: Response, expected_type: type[T]) -> T: if not response.ok: self._handle_failed_response(response) data = response.json() if not isinstance(data, expected_type): - raise APIError(response.status_code, errors=[f"Expected response type {expected_type.__name__}"]) + raise APIError( + response.status_code, + errors=[f"Expected response type {expected_type.__name__}"], + ) return data def _process_response_dict(self, response: Response) -> dict[str, Any]: @@ -57,43 +59,29 @@ def _process_response_dict(self, response: Response) -> dict[str, Any]: def _process_response_list(self, response: Response) -> list[dict[str, Any]]: return self._process_response(response, list) - def get( - self, - path: str, - params: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: - response = self._session.get(self._url(path), params=params, timeout=self._timeout) + def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + response = self._session.get( + self._url(path), params=params, timeout=self._timeout + ) return self._process_response_dict(response) - + def list( - self, - path: str, - params: Optional[dict[str, Any]] = None + self, path: str, params: Optional[dict[str, Any]] = None ) -> list[dict[str, Any]]: - response = self._session.get(self._url(path), params=params, timeout=self._timeout) + response = self._session.get( + self._url(path), params=params, timeout=self._timeout + ) return self._process_response_list(response) - def post( - self, - path: str, - json: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: + def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: response = self._session.post(self._url(path), json=json, timeout=self._timeout) return self._process_response_dict(response) - def put( - self, - path: str, - json: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: + def put(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: response = self._session.put(self._url(path), json=json, timeout=self._timeout) return self._process_response_dict(response) - def patch( - self, - path: str, - json: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: + def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: response = self._session.patch(self._url(path), json=json, timeout=self._timeout) return self._process_response_dict(response) @@ -102,11 +90,11 @@ def delete(self, path: str) -> dict[str, Any]: return self._process_response_dict(response) -def _extract_errors(data: dict[str, Any]) -> list[str]: +def _extract_errors(data: dict[str, Any]) -> list[str]: def flatten_errors(errors: Any) -> list[str]: if isinstance(errors, list): return [str(error) for error in errors] - + if isinstance(errors, dict): flat_errors = [] for key, value in errors.items(): @@ -117,10 +105,10 @@ def flatten_errors(errors: Any) -> list[str]: return flat_errors return [str(errors)] - + if "errors" in data: return flatten_errors(data["errors"]) - + if "error" in data: return flatten_errors(data["error"]) diff --git a/mailtrap/models/base.py b/mailtrap/models/base.py deleted file mode 100644 index 16b220f..0000000 --- a/mailtrap/models/base.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass, fields, is_dataclass -from typing import Any, Type, TypeVar, get_args, get_origin - - -T = TypeVar("T", bound="BaseModel") - - -@dataclass -class BaseModel: - @classmethod - def from_dict(cls: Type[T], data: dict[str, Any]) -> T: - values: dict[str, Any] = {} - for field in fields(cls): - value = data.get(field.name) - - if value is None: - values[field.name] = None - continue - - field_type = field.type - values[field.name] = cls._parse_value(value, field_type) - - return cls(**values) - - @classmethod - def _parse_value(cls, value: Any, field_type: Any) -> Any: - origin = get_origin(field_type) - - if origin is list: - item_type = get_args(field_type)[0] - return [cls._parse_value(item, item_type) for item in value] - - if is_dataclass(field_type) and hasattr(field_type, "from_dict"): - return field_type.from_dict(value) - - return value - - - -@dataclass -class DeletedObject(BaseModel): - id: str diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py deleted file mode 100644 index 6e80283..0000000 --- a/mailtrap/models/projects.py +++ /dev/null @@ -1,20 +0,0 @@ -from dataclasses import dataclass - -from mailtrap.models.base import BaseModel -from mailtrap.models.inboxes import Inbox -from mailtrap.models.permissions import Permissions - - -@dataclass -class ShareLinks(BaseModel): - admin: str - viewer: str - - -@dataclass -class Project(BaseModel): - id: str - name: str - share_links: ShareLinks - inboxes: list[Inbox] - permissions: Permissions diff --git a/mailtrap/models/__init__.py b/mailtrap/schemas/__init__.py similarity index 100% rename from mailtrap/models/__init__.py rename to mailtrap/schemas/__init__.py diff --git a/mailtrap/schemas/base.py b/mailtrap/schemas/base.py new file mode 100644 index 0000000..01df9ee --- /dev/null +++ b/mailtrap/schemas/base.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class DeletedObject(BaseModel): + id: int diff --git a/mailtrap/models/inboxes.py b/mailtrap/schemas/inboxes.py similarity index 63% rename from mailtrap/models/inboxes.py rename to mailtrap/schemas/inboxes.py index 47d82fd..79f986b 100644 --- a/mailtrap/models/inboxes.py +++ b/mailtrap/schemas/inboxes.py @@ -1,10 +1,9 @@ -from dataclasses import dataclass -from mailtrap.models.base import BaseModel -from mailtrap.models.permissions import Permissions +from typing import Optional +from pydantic import BaseModel +from mailtrap.schemas.permissions import Permissions -@dataclass class Inbox(BaseModel): id: int name: str @@ -27,5 +26,5 @@ class Inbox(BaseModel): pop3_ports: list[int] max_message_size: int permissions: Permissions - password: str | None = None # Password is only available if you have admin permissions for the inbox. - last_message_sent_at: str | None = None + password: Optional[str] = None # Password is only available if you have admin permissions for the inbox. + last_message_sent_at: Optional[str] = None diff --git a/mailtrap/models/permissions.py b/mailtrap/schemas/permissions.py similarity index 56% rename from mailtrap/models/permissions.py rename to mailtrap/schemas/permissions.py index dd23a53..f36ef72 100644 --- a/mailtrap/models/permissions.py +++ b/mailtrap/schemas/permissions.py @@ -1,9 +1,6 @@ -from dataclasses import dataclass +from pydantic import BaseModel -from mailtrap.models.base import BaseModel - -@dataclass class Permissions(BaseModel): can_read: bool can_update: bool diff --git a/mailtrap/schemas/projects.py b/mailtrap/schemas/projects.py new file mode 100644 index 0000000..e728096 --- /dev/null +++ b/mailtrap/schemas/projects.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from mailtrap.schemas.inboxes import Inbox +from mailtrap.schemas.permissions import Permissions + + +class ShareLinks(BaseModel): + admin: str + viewer: str + + +class Project(BaseModel): + id: int + name: str + share_links: ShareLinks + inboxes: list[Inbox] + permissions: Permissions diff --git a/requirements.test.txt b/requirements.test.txt index 6378454..ac5a9e7 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,2 +1,4 @@ +-r requirements.txt + pytest>=7.0.1 responses>=0.17.0 diff --git a/requirements.txt b/requirements.txt index ee88e26..db5664a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ requests>=2.26.0 +pydantic>=2.11.7 diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index 9239baa..c17f0d6 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -7,11 +7,11 @@ from mailtrap.config import MAILTRAP_HOST from mailtrap.exceptions import APIError from mailtrap.http import HttpClient -from mailtrap.models.base import DeletedObject -from mailtrap.models.projects import Project +from mailtrap.schemas.base import DeletedObject +from mailtrap.schemas.projects import Project ACCOUNT_ID = "321" -PROJECT_ID = "123" +PROJECT_ID = 123 BASE_PROJECTS_URL = f"https://{MAILTRAP_HOST}/api/accounts/{ACCOUNT_ID}/projects" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 29d3679..c6af526 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -27,7 +27,8 @@ class TestMailtrapClient: @staticmethod def get_client(**kwargs: Any) -> mt.MailtrapClient: - props = {"token": "fake_token", **kwargs} + props = {**kwargs} + mt.MailtrapClient.configure_access_token("fake_token") return mt.MailtrapClient(**props) @pytest.mark.parametrize( @@ -83,7 +84,7 @@ def test_api_send_url_should_return_default_sending_url( def test_headers_should_return_appropriate_dict(self) -> None: client = self.get_client() - assert client.headers == { + assert client.get_default_headers() == { "Authorization": "Bearer fake_token", "Content-Type": "application/json", "User-Agent": ( @@ -103,7 +104,7 @@ def test_send_should_handle_success_response(self, mail: mt.BaseMail) -> None: assert result == response_body assert len(responses.calls) == 1 request = responses.calls[0].request # type: ignore - assert request.headers.items() >= client.headers.items() + assert request.headers.items() >= client.get_default_headers().items() assert request.body == json.dumps(mail.api_data).encode() @responses.activate From efa073738d8878b16349cac5138266bf70580693 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Mon, 4 Aug 2025 20:43:35 +0300 Subject: [PATCH 05/13] Fix issue #29: Add tests for error responses, add user_input validation for create and update actions --- examples/testing/projects.py | 2 +- mailtrap/api/resources/projects.py | 3 + mailtrap/http.py | 4 +- mailtrap/schemas/inboxes.py | 7 +- mailtrap/schemas/projects.py | 6 ++ tests/unit/api/test_projects.py | 164 ++++++++++++++++++++++++++--- 6 files changed, 169 insertions(+), 17 deletions(-) diff --git a/examples/testing/projects.py b/examples/testing/projects.py index f4ff682..a18af78 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -24,5 +24,5 @@ def find_project_by_name(project_name: str, projects: list[Project]) -> Optional projects = projects_api.get_list() project_id = find_project_by_name(project_name, projects) project = projects_api.get_by_id(project_id) -updated_projected = projects_api.update(project_id, "Updated-project-name") +updated_project = projects_api.update(project_id, "Updated-project-name") deleted_object = projects_api.delete(project_id) diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py index 108d18b..ea13f1d 100644 --- a/mailtrap/api/resources/projects.py +++ b/mailtrap/api/resources/projects.py @@ -1,6 +1,7 @@ from mailtrap.http import HttpClient from mailtrap.schemas.base import DeletedObject from mailtrap.schemas.projects import Project +from mailtrap.schemas.projects import ProjectInput class ProjectsApi: @@ -19,6 +20,7 @@ def get_by_id(self, project_id: int) -> Project: return Project(**response) def create(self, project_name: str) -> Project: + ProjectInput(name=project_name) response = self.client.post( f"/api/accounts/{self.account_id}/projects", json={"project": {"name": project_name}}, @@ -26,6 +28,7 @@ def create(self, project_name: str) -> Project: return Project(**response) def update(self, project_id: int, project_name: str) -> Project: + ProjectInput(name=project_name) response = self.client.patch( f"/api/accounts/{self.account_id}/projects/{project_id}", json={"project": {"name": project_name}}, diff --git a/mailtrap/http.py b/mailtrap/http.py index 6898d8e..68dcd66 100644 --- a/mailtrap/http.py +++ b/mailtrap/http.py @@ -32,8 +32,8 @@ def _handle_failed_response(self, response: Response) -> NoReturn: status_code = response.status_code try: data = response.json() - except ValueError: - raise APIError(status_code, errors=["Unknown Error"]) + except ValueError as exc: + raise APIError(status_code, errors=["Unknown Error"]) from exc errors = _extract_errors(data) diff --git a/mailtrap/schemas/inboxes.py b/mailtrap/schemas/inboxes.py index 79f986b..73bf0e1 100644 --- a/mailtrap/schemas/inboxes.py +++ b/mailtrap/schemas/inboxes.py @@ -1,6 +1,7 @@ - from typing import Optional + from pydantic import BaseModel + from mailtrap.schemas.permissions import Permissions @@ -26,5 +27,7 @@ class Inbox(BaseModel): pop3_ports: list[int] max_message_size: int permissions: Permissions - password: Optional[str] = None # Password is only available if you have admin permissions for the inbox. + password: Optional[str] = ( + None # Password is only available if you have admin permissions for the inbox. + ) last_message_sent_at: Optional[str] = None diff --git a/mailtrap/schemas/projects.py b/mailtrap/schemas/projects.py index e728096..831ed1c 100644 --- a/mailtrap/schemas/projects.py +++ b/mailtrap/schemas/projects.py @@ -1,4 +1,6 @@ from pydantic import BaseModel +from pydantic import Field + from mailtrap.schemas.inboxes import Inbox from mailtrap.schemas.permissions import Permissions @@ -14,3 +16,7 @@ class Project(BaseModel): share_links: ShareLinks inboxes: list[Inbox] permissions: Permissions + + +class ProjectInput(BaseModel): + name: str = Field(min_length=2, max_length=100) diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index c17f0d6..d89edf1 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -2,6 +2,7 @@ import pytest import responses +from pydantic import ValidationError from mailtrap.api.resources.projects import ProjectsApi from mailtrap.config import MAILTRAP_HOST @@ -40,6 +41,34 @@ def sample_project_dict() -> dict[str, Any]: class TestProjectsApi: + + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + (401, {"error": "Incorrect API token"}, "Incorrect API token"), + (403, {"errors": "Access forbidden"}, "Access forbidden"), + ], + ) + @responses.activate + def test_get_list_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.add( + responses.GET, + BASE_PROJECTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.get_list() + + assert expected_error_message in str(exc_info.value) + @responses.activate def test_get_list_should_return_project_list( self, client: ProjectsApi, sample_project_dict: dict @@ -57,20 +86,34 @@ def test_get_list_should_return_project_list( assert all(isinstance(p, Project) for p in projects) assert projects[0].id == PROJECT_ID + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + (401, {"error": "Incorrect API token"}, "Incorrect API token"), + (403, {"errors": "Access forbidden"}, "Access forbidden"), + (404, {"error": "Not Found"}, "Not Found"), + ], + ) @responses.activate - def test_get_by_id_should_raise_not_found_error(self, client: ProjectsApi) -> None: + def test_get_by_id_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.GET, url, - status=404, - json={"error": "Not Found"}, + status=status_code, + json=response_json, ) with pytest.raises(APIError) as exc_info: client.get_by_id(PROJECT_ID) - assert "Not Found" in str(exc_info) + assert expected_error_message in str(exc_info.value) @responses.activate def test_get_by_id_should_return_single_project( @@ -89,6 +132,54 @@ def test_get_by_id_should_return_single_project( assert isinstance(project, Project) assert project.id == PROJECT_ID + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + (401, {"error": "Incorrect API token"}, "Incorrect API token"), + (403, {"errors": "Access forbidden"}, "Access forbidden"), + ], + ) + @responses.activate + def test_create_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: + responses.add( + responses.POST, + BASE_PROJECTS_URL, + status=status_code, + json=response_json, + ) + + with pytest.raises(APIError) as exc_info: + client.create(project_name="New Project") + + assert expected_error_message in str(exc_info.value) + + @pytest.mark.parametrize( + "project_name, expected_errors", + [ + (None, ["Input should be a valid string"]), + ("", ["String should have at least 2 characters"]), + ("a", ["String should have at least 2 characters"]), + ("a" * 101, ["String should have at most 100 characters"]), + ], + ) + def test_create_should_raise_validation_error_on_pydantic_validation( + self, client: ProjectsApi, project_name: str, expected_errors: list[str] + ) -> None: + with pytest.raises(ValidationError) as exc_info: + client.create(project_name=project_name) + + errors = exc_info.value.errors() + error_messages = [err["msg"] for err in errors] + + for expected_msg in expected_errors: + assert any(expected_msg in actual_msg for actual_msg in error_messages) + @responses.activate def test_create_should_return_new_project( self, client: ProjectsApi, sample_project_dict: dict @@ -105,20 +196,55 @@ def test_create_should_return_new_project( assert isinstance(project, Project) assert project.name == "Test Project" + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + (401, {"error": "Incorrect API token"}, "Incorrect API token"), + (403, {"errors": "Access forbidden"}, "Access forbidden"), + (404, {"error": "Not Found"}, "Not Found"), + ], + ) @responses.activate - def test_update_should_raise_not_found_error(self, client: ProjectsApi) -> None: + def test_update_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.PATCH, url, - status=404, - json={"error": "Not Found"}, + status=status_code, + json=response_json, ) with pytest.raises(APIError) as exc_info: client.update(PROJECT_ID, project_name="Update Project Name") - assert "Not Found" in str(exc_info) + assert expected_error_message in str(exc_info.value) + + @pytest.mark.parametrize( + "project_name, expected_errors", + [ + (None, ["Input should be a valid string"]), + ("", ["String should have at least 2 characters"]), + ("a", ["String should have at least 2 characters"]), + ("a" * 101, ["String should have at most 100 characters"]), + ], + ) + def test_update_should_raise_validation_error_on_pydantic_validation( + self, client: ProjectsApi, project_name: str, expected_errors: list[str] + ) -> None: + with pytest.raises(ValidationError) as exc_info: + client.update(project_id=PROJECT_ID, project_name=project_name) + + errors = exc_info.value.errors() + error_messages = [err["msg"] for err in errors] + + for expected_msg in expected_errors: + assert any(expected_msg in actual_msg for actual_msg in error_messages) @responses.activate def test_update_should_return_updated_project( @@ -141,20 +267,34 @@ def test_update_should_return_updated_project( assert isinstance(project, Project) assert project.name == updated_name + @pytest.mark.parametrize( + "status_code,response_json,expected_error_message", + [ + (401, {"error": "Incorrect API token"}, "Incorrect API token"), + (403, {"errors": "Access forbidden"}, "Access forbidden"), + (404, {"error": "Not Found"}, "Not Found"), + ], + ) @responses.activate - def test_delete_should_raise_not_found_error(self, client: ProjectsApi) -> None: + def test_delete_should_raise_api_errors( + self, + client: ProjectsApi, + status_code: int, + response_json: dict, + expected_error_message: str, + ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" responses.add( responses.DELETE, url, - status=404, - json={"error": "Not Found"}, + status=status_code, + json=response_json, ) with pytest.raises(APIError) as exc_info: client.delete(PROJECT_ID) - assert "Not Found" in str(exc_info) + assert expected_error_message in str(exc_info.value) @responses.activate def test_delete_should_return_deleted_object(self, client: ProjectsApi) -> None: From 7bb372556204f77f5b0d2fa34f2ffe7077c7068b Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Tue, 5 Aug 2025 02:03:39 +0300 Subject: [PATCH 06/13] Fix issue #29: Change the way how to set access token --- examples/testing/projects.py | 38 ++++++++++++++++++++++++++++++------ mailtrap/client.py | 32 ++++++++++-------------------- tests/unit/test_client.py | 7 +++---- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/examples/testing/projects.py b/examples/testing/projects.py index a18af78..65e228e 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -1,8 +1,13 @@ +import logging from typing import Optional from mailtrap import MailtrapClient from mailtrap.schemas.projects import Project +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) + API_TOKEN = "YOU_API_TOKEN" ACCOUNT_ID = "YOU_ACCOUNT_ID" @@ -14,15 +19,36 @@ def find_project_by_name(project_name: str, projects: list[Project]) -> Optional return None -MailtrapClient.configure_access_token(API_TOKEN) -testing_api = MailtrapClient.get_testing_api(ACCOUNT_ID) +logging.info("Starting Mailtrap Testing API example...") + +client = MailtrapClient(token=API_TOKEN) +testing_api = client.get_testing_api(ACCOUNT_ID) projects_api = testing_api.projects project_name = "Example-project" - created_project = projects_api.create(project_name=project_name) +logging.info(f"Project created! ID: {created_project.id}, Name: {created_project.name}") + projects = projects_api.get_list() +logging.info(f"Found {len(projects)} projects:") +for project in projects: + logging.info(f" - {project.name} (ID: {project.id})") + project_id = find_project_by_name(project_name, projects) -project = projects_api.get_by_id(project_id) -updated_project = projects_api.update(project_id, "Updated-project-name") -deleted_object = projects_api.delete(project_id) +if project_id: + logging.info(f"Found project with ID: {project_id}") +else: + logging.info("Project not found in the list") + +if project_id: + project = projects_api.get_by_id(project_id) + logging.info(f"Project details: {project.name} (ID: {project.id})") + + new_name = "Updated-project-name" + updated_project = projects_api.update(project_id, new_name) + logging.info(f"Project updated!ID: {project_id}, New name: {updated_project.name}") + + deleted_object = projects_api.delete(project_id) + logging.info(f"Project deleted! Deleted ID: {deleted_object.id}") + +logging.info("Example completed successfully!") diff --git a/mailtrap/client.py b/mailtrap/client.py index 18c5c7b..24ad034 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -19,16 +19,16 @@ class MailtrapClient: BULK_HOST = "bulk.api.mailtrap.io" SANDBOX_HOST = "sandbox.api.mailtrap.io" - _default_token: Optional[str] = None - def __init__( self, + token: str, api_host: Optional[str] = None, api_port: int = DEFAULT_PORT, bulk: bool = False, sandbox: bool = False, inbox_id: Optional[str] = None, ) -> None: + self.token = token self.api_host = api_host self.api_port = api_port self.bulk = bulk @@ -37,20 +37,15 @@ def __init__( self._validate_itself() - @classmethod - def configure_access_token(cls, token: str) -> None: - cls._default_token = token - - @classmethod - def get_testing_api( - cls, account_id: str, inbox_id: Optional[str] = None - ) -> TestingApi: - http_client = HttpClient(host=MAILTRAP_HOST, headers=cls.get_default_headers()) - return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client) + def get_testing_api(self, account_id: str) -> TestingApi: + http_client = HttpClient(host=MAILTRAP_HOST, headers=self.get_headers()) + return TestingApi( + account_id=account_id, inbox_id=self.inbox_id, client=http_client + ) def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: response = requests.post( - self.api_send_url, headers=self.get_default_headers(), json=mail.api_data + self.api_send_url, headers=self.get_headers(), json=mail.api_data ) if response.ok: @@ -71,16 +66,9 @@ def api_send_url(self) -> str: return url - @classmethod - def get_default_headers(cls) -> dict[str, str]: - if cls._default_token is None: - raise ValueError( - "Access token is not configured. " - "Call MailtrapClient.configure_token(...) first." - ) - + def get_headers(self) -> dict[str, str]: return { - "Authorization": f"Bearer {cls._default_token}", + "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", "User-Agent": ( "mailtrap-python (https://github.com/railsware/mailtrap-python)" diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index c6af526..ffd4c9d 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -27,8 +27,7 @@ class TestMailtrapClient: @staticmethod def get_client(**kwargs: Any) -> mt.MailtrapClient: - props = {**kwargs} - mt.MailtrapClient.configure_access_token("fake_token") + props = {"token": "fake_token", **kwargs} return mt.MailtrapClient(**props) @pytest.mark.parametrize( @@ -84,7 +83,7 @@ def test_api_send_url_should_return_default_sending_url( def test_headers_should_return_appropriate_dict(self) -> None: client = self.get_client() - assert client.get_default_headers() == { + assert client.get_headers() == { "Authorization": "Bearer fake_token", "Content-Type": "application/json", "User-Agent": ( @@ -104,7 +103,7 @@ def test_send_should_handle_success_response(self, mail: mt.BaseMail) -> None: assert result == response_body assert len(responses.calls) == 1 request = responses.calls[0].request # type: ignore - assert request.headers.items() >= client.get_default_headers().items() + assert request.headers.items() >= client.get_headers().items() assert request.body == json.dumps(mail.api_data).encode() @responses.activate From 1f3ff3ace314483b7f8263db3ccaebe1366cee8e Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 6 Aug 2025 00:00:10 +0300 Subject: [PATCH 07/13] Fix issue #29: Split Mailtrap client into current and new --- examples/testing/projects.py | 6 +-- mailtrap/__init__.py | 1 + mailtrap/api/resources/projects.py | 7 +-- mailtrap/client.py | 31 ++++++++---- mailtrap/config.py | 2 +- mailtrap/http.py | 45 ++++++----------- mailtrap/{schemas => models}/__init__.py | 0 mailtrap/{schemas => models}/base.py | 0 mailtrap/{schemas => models}/inboxes.py | 2 +- mailtrap/{schemas => models}/permissions.py | 0 mailtrap/{schemas => models}/projects.py | 8 +--- pyproject.toml | 1 + tests/unit/api/test_projects.py | 53 ++------------------- tests/unit/test_client.py | 21 +++++++- 14 files changed, 71 insertions(+), 106 deletions(-) rename mailtrap/{schemas => models}/__init__.py (100%) rename mailtrap/{schemas => models}/base.py (100%) rename mailtrap/{schemas => models}/inboxes.py (93%) rename mailtrap/{schemas => models}/permissions.py (100%) rename mailtrap/{schemas => models}/projects.py (58%) diff --git a/examples/testing/projects.py b/examples/testing/projects.py index 65e228e..f781c27 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -1,8 +1,8 @@ import logging from typing import Optional -from mailtrap import MailtrapClient -from mailtrap.schemas.projects import Project +from mailtrap import MailtrapApiClient +from mailtrap.models.projects import Project logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" @@ -21,7 +21,7 @@ def find_project_by_name(project_name: str, projects: list[Project]) -> Optional logging.info("Starting Mailtrap Testing API example...") -client = MailtrapClient(token=API_TOKEN) +client = MailtrapApiClient(token=API_TOKEN) testing_api = client.get_testing_api(ACCOUNT_ID) projects_api = testing_api.projects diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index f03b693..055053e 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -1,3 +1,4 @@ +from .client import MailtrapApiClient from .client import MailtrapClient from .exceptions import APIError from .exceptions import AuthorizationError diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py index ea13f1d..29b130e 100644 --- a/mailtrap/api/resources/projects.py +++ b/mailtrap/api/resources/projects.py @@ -1,7 +1,6 @@ from mailtrap.http import HttpClient -from mailtrap.schemas.base import DeletedObject -from mailtrap.schemas.projects import Project -from mailtrap.schemas.projects import ProjectInput +from mailtrap.models.base import DeletedObject +from mailtrap.models.projects import Project class ProjectsApi: @@ -20,7 +19,6 @@ def get_by_id(self, project_id: int) -> Project: return Project(**response) def create(self, project_name: str) -> Project: - ProjectInput(name=project_name) response = self.client.post( f"/api/accounts/{self.account_id}/projects", json={"project": {"name": project_name}}, @@ -28,7 +26,6 @@ def create(self, project_name: str) -> Project: return Project(**response) def update(self, project_id: int, project_name: str) -> Project: - ProjectInput(name=project_name) response = self.client.patch( f"/api/accounts/{self.account_id}/projects/{project_id}", json={"project": {"name": project_name}}, diff --git a/mailtrap/client.py b/mailtrap/client.py index 24ad034..12e821e 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -5,7 +5,7 @@ import requests from mailtrap.api.testing import TestingApi -from mailtrap.config import MAILTRAP_HOST +from mailtrap.config import GENERAL_ENDPOINT from mailtrap.exceptions import APIError from mailtrap.exceptions import AuthorizationError from mailtrap.exceptions import ClientConfigurationError @@ -37,15 +37,9 @@ def __init__( self._validate_itself() - def get_testing_api(self, account_id: str) -> TestingApi: - http_client = HttpClient(host=MAILTRAP_HOST, headers=self.get_headers()) - return TestingApi( - account_id=account_id, inbox_id=self.inbox_id, client=http_client - ) - def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: response = requests.post( - self.api_send_url, headers=self.get_headers(), json=mail.api_data + self.api_send_url, headers=self.headers, json=mail.api_data ) if response.ok: @@ -66,7 +60,8 @@ def api_send_url(self) -> str: return url - def get_headers(self) -> dict[str, str]: + @property + def headers(self) -> dict[str, str]: return { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", @@ -106,3 +101,21 @@ def _validate_itself(self) -> None: if self.bulk and self.sandbox: raise ClientConfigurationError("bulk mode is not allowed in sandbox mode") + + +class MailtrapApiClient: + def __init__(self, token: str) -> None: + self.token = token + + def testing_api(self, account_id: str, inbox_id: str) -> TestingApi: + http_client = HttpClient(host=GENERAL_ENDPOINT, headers=self.get_headers()) + return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client) + + def get_headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + "User-Agent": ( + "mailtrap-python (https://github.com/railsware/mailtrap-python)" + ), + } diff --git a/mailtrap/config.py b/mailtrap/config.py index 49b341a..e08a1d4 100644 --- a/mailtrap/config.py +++ b/mailtrap/config.py @@ -1,3 +1,3 @@ -MAILTRAP_HOST = "mailtrap.io" +GENERAL_ENDPOINT = "mailtrap.io" DEFAULT_REQUEST_TIMEOUT = 30 # in seconds diff --git a/mailtrap/http.py b/mailtrap/http.py index 68dcd66..62f172a 100644 --- a/mailtrap/http.py +++ b/mailtrap/http.py @@ -1,7 +1,6 @@ from typing import Any from typing import NoReturn from typing import Optional -from typing import TypeVar from requests import Response from requests import Session @@ -10,8 +9,6 @@ from mailtrap.exceptions import APIError from mailtrap.exceptions import AuthorizationError -T = TypeVar("T") - class HttpClient: def __init__( @@ -42,52 +39,38 @@ def _handle_failed_response(self, response: Response) -> NoReturn: raise APIError(status_code, errors=errors) - def _process_response(self, response: Response, expected_type: type[T]) -> T: + def _process_response(self, response: Response) -> Any: if not response.ok: self._handle_failed_response(response) - data = response.json() - if not isinstance(data, expected_type): - raise APIError( - response.status_code, - errors=[f"Expected response type {expected_type.__name__}"], - ) - return data - - def _process_response_dict(self, response: Response) -> dict[str, Any]: - return self._process_response(response, dict) - - def _process_response_list(self, response: Response) -> list[dict[str, Any]]: - return self._process_response(response, list) + return response.json() - def get(self, path: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: response = self._session.get( self._url(path), params=params, timeout=self._timeout ) - return self._process_response_dict(response) + return self._process_response(response) - def list( - self, path: str, params: Optional[dict[str, Any]] = None - ) -> list[dict[str, Any]]: + def list(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: response = self._session.get( self._url(path), params=params, timeout=self._timeout ) - return self._process_response_list(response) + return self._process_response(response) - def post(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def post(self, path: str, json: Optional[dict[str, Any]] = None) -> Any: response = self._session.post(self._url(path), json=json, timeout=self._timeout) - return self._process_response_dict(response) + return self._process_response(response) - def put(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def put(self, path: str, json: Optional[dict[str, Any]] = None) -> Any: response = self._session.put(self._url(path), json=json, timeout=self._timeout) - return self._process_response_dict(response) + return self._process_response(response) - def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> dict[str, Any]: + def patch(self, path: str, json: Optional[dict[str, Any]] = None) -> Any: response = self._session.patch(self._url(path), json=json, timeout=self._timeout) - return self._process_response_dict(response) + return self._process_response(response) - def delete(self, path: str) -> dict[str, Any]: + def delete(self, path: str) -> Any: response = self._session.delete(self._url(path), timeout=self._timeout) - return self._process_response_dict(response) + return self._process_response(response) def _extract_errors(data: dict[str, Any]) -> list[str]: diff --git a/mailtrap/schemas/__init__.py b/mailtrap/models/__init__.py similarity index 100% rename from mailtrap/schemas/__init__.py rename to mailtrap/models/__init__.py diff --git a/mailtrap/schemas/base.py b/mailtrap/models/base.py similarity index 100% rename from mailtrap/schemas/base.py rename to mailtrap/models/base.py diff --git a/mailtrap/schemas/inboxes.py b/mailtrap/models/inboxes.py similarity index 93% rename from mailtrap/schemas/inboxes.py rename to mailtrap/models/inboxes.py index 73bf0e1..e21d743 100644 --- a/mailtrap/schemas/inboxes.py +++ b/mailtrap/models/inboxes.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from mailtrap.schemas.permissions import Permissions +from mailtrap.models.permissions import Permissions class Inbox(BaseModel): diff --git a/mailtrap/schemas/permissions.py b/mailtrap/models/permissions.py similarity index 100% rename from mailtrap/schemas/permissions.py rename to mailtrap/models/permissions.py diff --git a/mailtrap/schemas/projects.py b/mailtrap/models/projects.py similarity index 58% rename from mailtrap/schemas/projects.py rename to mailtrap/models/projects.py index 831ed1c..8301515 100644 --- a/mailtrap/schemas/projects.py +++ b/mailtrap/models/projects.py @@ -1,8 +1,8 @@ from pydantic import BaseModel from pydantic import Field -from mailtrap.schemas.inboxes import Inbox -from mailtrap.schemas.permissions import Permissions +from mailtrap.models.inboxes import Inbox +from mailtrap.models.permissions import Permissions class ShareLinks(BaseModel): @@ -16,7 +16,3 @@ class Project(BaseModel): share_links: ShareLinks inboxes: list[Inbox] permissions: Permissions - - -class ProjectInput(BaseModel): - name: str = Field(min_length=2, max_length=100) diff --git a/pyproject.toml b/pyproject.toml index 73834a3..f5cbc77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ requires-python = ">=3.9" dependencies = [ "requests>=2.26.0", + "pydantic>=2.11.7", ] [project.urls] diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index d89edf1..cd656af 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -2,23 +2,22 @@ import pytest import responses -from pydantic import ValidationError from mailtrap.api.resources.projects import ProjectsApi -from mailtrap.config import MAILTRAP_HOST +from mailtrap.config import GENERAL_ENDPOINT from mailtrap.exceptions import APIError from mailtrap.http import HttpClient -from mailtrap.schemas.base import DeletedObject -from mailtrap.schemas.projects import Project +from mailtrap.models.base import DeletedObject +from mailtrap.models.projects import Project ACCOUNT_ID = "321" PROJECT_ID = 123 -BASE_PROJECTS_URL = f"https://{MAILTRAP_HOST}/api/accounts/{ACCOUNT_ID}/projects" +BASE_PROJECTS_URL = f"https://{GENERAL_ENDPOINT}/api/accounts/{ACCOUNT_ID}/projects" @pytest.fixture def client() -> ProjectsApi: - return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(MAILTRAP_HOST)) + return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_ENDPOINT)) @pytest.fixture @@ -159,27 +158,6 @@ def test_create_should_raise_api_errors( assert expected_error_message in str(exc_info.value) - @pytest.mark.parametrize( - "project_name, expected_errors", - [ - (None, ["Input should be a valid string"]), - ("", ["String should have at least 2 characters"]), - ("a", ["String should have at least 2 characters"]), - ("a" * 101, ["String should have at most 100 characters"]), - ], - ) - def test_create_should_raise_validation_error_on_pydantic_validation( - self, client: ProjectsApi, project_name: str, expected_errors: list[str] - ) -> None: - with pytest.raises(ValidationError) as exc_info: - client.create(project_name=project_name) - - errors = exc_info.value.errors() - error_messages = [err["msg"] for err in errors] - - for expected_msg in expected_errors: - assert any(expected_msg in actual_msg for actual_msg in error_messages) - @responses.activate def test_create_should_return_new_project( self, client: ProjectsApi, sample_project_dict: dict @@ -225,27 +203,6 @@ def test_update_should_raise_api_errors( assert expected_error_message in str(exc_info.value) - @pytest.mark.parametrize( - "project_name, expected_errors", - [ - (None, ["Input should be a valid string"]), - ("", ["String should have at least 2 characters"]), - ("a", ["String should have at least 2 characters"]), - ("a" * 101, ["String should have at most 100 characters"]), - ], - ) - def test_update_should_raise_validation_error_on_pydantic_validation( - self, client: ProjectsApi, project_name: str, expected_errors: list[str] - ) -> None: - with pytest.raises(ValidationError) as exc_info: - client.update(project_id=PROJECT_ID, project_name=project_name) - - errors = exc_info.value.errors() - error_messages = [err["msg"] for err in errors] - - for expected_msg in expected_errors: - assert any(expected_msg in actual_msg for actual_msg in error_messages) - @responses.activate def test_update_should_return_updated_project( self, client: ProjectsApi, sample_project_dict: dict diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index ffd4c9d..bcb4bd0 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -83,7 +83,7 @@ def test_api_send_url_should_return_default_sending_url( def test_headers_should_return_appropriate_dict(self) -> None: client = self.get_client() - assert client.get_headers() == { + assert client.headers == { "Authorization": "Bearer fake_token", "Content-Type": "application/json", "User-Agent": ( @@ -103,7 +103,7 @@ def test_send_should_handle_success_response(self, mail: mt.BaseMail) -> None: assert result == response_body assert len(responses.calls) == 1 request = responses.calls[0].request # type: ignore - assert request.headers.items() >= client.get_headers().items() + assert request.headers.items() >= client.headers.items() assert request.body == json.dumps(mail.api_data).encode() @responses.activate @@ -142,3 +142,20 @@ def test_send_should_raise_api_error_for_500_status_code( with pytest.raises(mt.APIError): client.send(mail) + + +class TestMailtrapApiClient: + @pytest.fixture + def api_client(self): + return mt.MailtrapApiClient(token="fake_token") + + def test_headers_should_return_appropriate_dict( + self, api_client: mt.MailtrapApiClient + ) -> None: + assert api_client.get_headers() == { + "Authorization": "Bearer fake_token", + "Content-Type": "application/json", + "User-Agent": ( + "mailtrap-python (https://github.com/railsware/mailtrap-python)" + ), + } From 6d4dce93389cbc6ddad60bdc04f5c7d25e340565 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 6 Aug 2025 10:04:20 +0300 Subject: [PATCH 08/13] Fix issue #29: Remove unused import in project models --- mailtrap/models/projects.py | 1 - 1 file changed, 1 deletion(-) diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py index 8301515..ce3baf8 100644 --- a/mailtrap/models/projects.py +++ b/mailtrap/models/projects.py @@ -1,5 +1,4 @@ from pydantic import BaseModel -from pydantic import Field from mailtrap.models.inboxes import Inbox from mailtrap.models.permissions import Permissions From 92c788ad17e4a58e6b4b1586d4bfebf1ab6efcde Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Thu, 7 Aug 2025 00:28:30 +0300 Subject: [PATCH 09/13] Fix issue #29: Fixed names, changed location of methods --- examples/testing/projects.py | 49 +++-------- mailtrap/api/resources/projects.py | 4 +- mailtrap/client.py | 7 +- mailtrap/http.py | 86 +++++++++---------- mailtrap/models/{base.py => common.py} | 0 tests/conftest.py | 11 +++ tests/unit/api/test_projects.py | 111 +++++++++++++++++-------- tests/unit/test_client.py | 2 +- 8 files changed, 145 insertions(+), 125 deletions(-) rename mailtrap/models/{base.py => common.py} (100%) create mode 100644 tests/conftest.py diff --git a/examples/testing/projects.py b/examples/testing/projects.py index f781c27..2822787 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -1,54 +1,25 @@ -import logging -from typing import Optional - from mailtrap import MailtrapApiClient from mailtrap.models.projects import Project -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) - API_TOKEN = "YOU_API_TOKEN" ACCOUNT_ID = "YOU_ACCOUNT_ID" - -def find_project_by_name(project_name: str, projects: list[Project]) -> Optional[str]: - filtered_projects = [project for project in projects if project.name == project_name] - if filtered_projects: - return filtered_projects[0].id - return None - - -logging.info("Starting Mailtrap Testing API example...") - client = MailtrapApiClient(token=API_TOKEN) -testing_api = client.get_testing_api(ACCOUNT_ID) +testing_api = client.testing_api(ACCOUNT_ID) projects_api = testing_api.projects -project_name = "Example-project" -created_project = projects_api.create(project_name=project_name) -logging.info(f"Project created! ID: {created_project.id}, Name: {created_project.name}") -projects = projects_api.get_list() -logging.info(f"Found {len(projects)} projects:") -for project in projects: - logging.info(f" - {project.name} (ID: {project.id})") +def list_projects() -> list[Project]: + return projects_api.get_list() + -project_id = find_project_by_name(project_name, projects) -if project_id: - logging.info(f"Found project with ID: {project_id}") -else: - logging.info("Project not found in the list") +def create_project(project_name: str) -> Project: + return projects_api.create(project_name=project_name) -if project_id: - project = projects_api.get_by_id(project_id) - logging.info(f"Project details: {project.name} (ID: {project.id})") - new_name = "Updated-project-name" - updated_project = projects_api.update(project_id, new_name) - logging.info(f"Project updated!ID: {project_id}, New name: {updated_project.name}") +def update_project(project_id: str, new_name: str) -> Project: + return projects_api.update(project_id, new_name) - deleted_object = projects_api.delete(project_id) - logging.info(f"Project deleted! Deleted ID: {deleted_object.id}") -logging.info("Example completed successfully!") +def delete_project(project_id: str): + return projects_api.delete(project_id) diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py index 29b130e..5faaef8 100644 --- a/mailtrap/api/resources/projects.py +++ b/mailtrap/api/resources/projects.py @@ -1,5 +1,5 @@ from mailtrap.http import HttpClient -from mailtrap.models.base import DeletedObject +from mailtrap.models.common import DeletedObject from mailtrap.models.projects import Project @@ -9,7 +9,7 @@ def __init__(self, client: HttpClient, account_id: str) -> None: self.client = client def get_list(self) -> list[Project]: - response = self.client.list(f"/api/accounts/{self.account_id}/projects") + response = self.client.get(f"/api/accounts/{self.account_id}/projects") return [Project(**project) for project in response] def get_by_id(self, project_id: int) -> Project: diff --git a/mailtrap/client.py b/mailtrap/client.py index 12e821e..e2c644c 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -107,11 +107,12 @@ class MailtrapApiClient: def __init__(self, token: str) -> None: self.token = token - def testing_api(self, account_id: str, inbox_id: str) -> TestingApi: - http_client = HttpClient(host=GENERAL_ENDPOINT, headers=self.get_headers()) + def testing_api(self, account_id: str, inbox_id: Optional[str] = None) -> TestingApi: + http_client = HttpClient(host=GENERAL_ENDPOINT, headers=self.headers) return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client) - def get_headers(self) -> dict[str, str]: + @property + def headers(self) -> dict[str, str]: return { "Authorization": f"Bearer {self.token}", "Content-Type": "application/json", diff --git a/mailtrap/http.py b/mailtrap/http.py index 62f172a..4e0c9bf 100644 --- a/mailtrap/http.py +++ b/mailtrap/http.py @@ -22,40 +22,12 @@ def __init__( self._session.headers.update(headers or {}) self._timeout = timeout - def _url(self, path: str) -> str: - return f"https://{self._host}/{path.lstrip('/')}" - - def _handle_failed_response(self, response: Response) -> NoReturn: - status_code = response.status_code - try: - data = response.json() - except ValueError as exc: - raise APIError(status_code, errors=["Unknown Error"]) from exc - - errors = _extract_errors(data) - - if status_code == 401: - raise AuthorizationError(errors=errors) - - raise APIError(status_code, errors=errors) - - def _process_response(self, response: Response) -> Any: - if not response.ok: - self._handle_failed_response(response) - return response.json() - def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: response = self._session.get( self._url(path), params=params, timeout=self._timeout ) return self._process_response(response) - def list(self, path: str, params: Optional[dict[str, Any]] = None) -> Any: - response = self._session.get( - self._url(path), params=params, timeout=self._timeout - ) - return self._process_response(response) - def post(self, path: str, json: Optional[dict[str, Any]] = None) -> Any: response = self._session.post(self._url(path), json=json, timeout=self._timeout) return self._process_response(response) @@ -72,27 +44,49 @@ def delete(self, path: str) -> Any: response = self._session.delete(self._url(path), timeout=self._timeout) return self._process_response(response) + def _url(self, path: str) -> str: + return f"https://{self._host}/{path.lstrip('/')}" + + def _process_response(self, response: Response) -> Any: + if not response.ok: + self._handle_failed_response(response) + return response.json() + + def _handle_failed_response(self, response: Response) -> NoReturn: + status_code = response.status_code + try: + data = response.json() + except ValueError as exc: + raise APIError(status_code, errors=["Unknown Error"]) from exc + + errors = self._extract_errors(data) + + if status_code == 401: + raise AuthorizationError(errors=errors) + + raise APIError(status_code, errors=errors) -def _extract_errors(data: dict[str, Any]) -> list[str]: - def flatten_errors(errors: Any) -> list[str]: - if isinstance(errors, list): - return [str(error) for error in errors] + @staticmethod + def _extract_errors(data: dict[str, Any]) -> list[str]: + def flatten_errors(errors: Any) -> list[str]: + if isinstance(errors, list): + return [str(error) for error in errors] - if isinstance(errors, dict): - flat_errors = [] - for key, value in errors.items(): - if isinstance(value, list): - flat_errors.extend([f"{key}: {v}" for v in value]) - else: - flat_errors.append(f"{key}: {value}") - return flat_errors + if isinstance(errors, dict): + flat_errors = [] + for key, value in errors.items(): + if isinstance(value, list): + flat_errors.extend([f"{key}: {v}" for v in value]) + else: + flat_errors.append(f"{key}: {value}") + return flat_errors - return [str(errors)] + return [str(errors)] - if "errors" in data: - return flatten_errors(data["errors"]) + if "errors" in data: + return flatten_errors(data["errors"]) - if "error" in data: - return flatten_errors(data["error"]) + if "error" in data: + return flatten_errors(data["error"]) - return ["Unknown error"] + return ["Unknown error"] diff --git a/mailtrap/models/base.py b/mailtrap/models/common.py similarity index 100% rename from mailtrap/models/base.py rename to mailtrap/models/common.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3324ff3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,11 @@ +UNAUTHORIZED_STATUS_CODE = 401 +UNAUTHORIZED_ERROR_MESSAGE = "Incorrect API token" +UNAUTHORIZED_RESPONSE = {"error": UNAUTHORIZED_ERROR_MESSAGE} + +FORBIDDEN_STATUS_CODE = 403 +FORBIDDEN_ERROR_MESSAGE = "Access forbidden" +FORBIDDEN_RESPONSE = {"errors": FORBIDDEN_ERROR_MESSAGE} + +NOT_FOUND_STATUS_CODE = 404 +NOT_FOUND_ERROR_MESSAGE = "Not Found" +NOT_FOUND_RESPONSE = {"error": NOT_FOUND_ERROR_MESSAGE} diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index cd656af..f2b374d 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -7,8 +7,9 @@ from mailtrap.config import GENERAL_ENDPOINT from mailtrap.exceptions import APIError from mailtrap.http import HttpClient -from mailtrap.models.base import DeletedObject +from mailtrap.models.common import DeletedObject from mailtrap.models.projects import Project +from tests import conftest ACCOUNT_ID = "321" PROJECT_ID = 123 @@ -44,8 +45,16 @@ class TestProjectsApi: @pytest.mark.parametrize( "status_code,response_json,expected_error_message", [ - (401, {"error": "Incorrect API token"}, "Incorrect API token"), - (403, {"errors": "Access forbidden"}, "Access forbidden"), + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -56,8 +65,7 @@ def test_get_list_should_raise_api_errors( response_json: dict, expected_error_message: str, ) -> None: - responses.add( - responses.GET, + responses.get( BASE_PROJECTS_URL, status=status_code, json=response_json, @@ -72,8 +80,7 @@ def test_get_list_should_raise_api_errors( def test_get_list_should_return_project_list( self, client: ProjectsApi, sample_project_dict: dict ) -> None: - responses.add( - responses.GET, + responses.get( BASE_PROJECTS_URL, json=[sample_project_dict], status=200, @@ -88,9 +95,21 @@ def test_get_list_should_return_project_list( @pytest.mark.parametrize( "status_code,response_json,expected_error_message", [ - (401, {"error": "Incorrect API token"}, "Incorrect API token"), - (403, {"errors": "Access forbidden"}, "Access forbidden"), - (404, {"error": "Not Found"}, "Not Found"), + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -102,8 +121,7 @@ def test_get_by_id_should_raise_api_errors( expected_error_message: str, ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" - responses.add( - responses.GET, + responses.get( url, status=status_code, json=response_json, @@ -119,8 +137,7 @@ def test_get_by_id_should_return_single_project( self, client: ProjectsApi, sample_project_dict: dict ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" - responses.add( - responses.GET, + responses.get( url, json=sample_project_dict, status=200, @@ -134,8 +151,16 @@ def test_get_by_id_should_return_single_project( @pytest.mark.parametrize( "status_code,response_json,expected_error_message", [ - (401, {"error": "Incorrect API token"}, "Incorrect API token"), - (403, {"errors": "Access forbidden"}, "Access forbidden"), + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -146,8 +171,7 @@ def test_create_should_raise_api_errors( response_json: dict, expected_error_message: str, ) -> None: - responses.add( - responses.POST, + responses.post( BASE_PROJECTS_URL, status=status_code, json=response_json, @@ -162,8 +186,7 @@ def test_create_should_raise_api_errors( def test_create_should_return_new_project( self, client: ProjectsApi, sample_project_dict: dict ) -> None: - responses.add( - responses.POST, + responses.post( BASE_PROJECTS_URL, json=sample_project_dict, status=201, @@ -177,9 +200,21 @@ def test_create_should_return_new_project( @pytest.mark.parametrize( "status_code,response_json,expected_error_message", [ - (401, {"error": "Incorrect API token"}, "Incorrect API token"), - (403, {"errors": "Access forbidden"}, "Access forbidden"), - (404, {"error": "Not Found"}, "Not Found"), + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -191,8 +226,7 @@ def test_update_should_raise_api_errors( expected_error_message: str, ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" - responses.add( - responses.PATCH, + responses.patch( url, status=status_code, json=response_json, @@ -212,8 +246,7 @@ def test_update_should_return_updated_project( updated_project_dict = sample_project_dict.copy() updated_project_dict["name"] = updated_name - responses.add( - responses.PATCH, + responses.patch( url, json=updated_project_dict, status=200, @@ -227,9 +260,21 @@ def test_update_should_return_updated_project( @pytest.mark.parametrize( "status_code,response_json,expected_error_message", [ - (401, {"error": "Incorrect API token"}, "Incorrect API token"), - (403, {"errors": "Access forbidden"}, "Access forbidden"), - (404, {"error": "Not Found"}, "Not Found"), + ( + conftest.UNAUTHORIZED_STATUS_CODE, + conftest.UNAUTHORIZED_RESPONSE, + conftest.UNAUTHORIZED_ERROR_MESSAGE, + ), + ( + conftest.FORBIDDEN_STATUS_CODE, + conftest.FORBIDDEN_RESPONSE, + conftest.FORBIDDEN_ERROR_MESSAGE, + ), + ( + conftest.NOT_FOUND_STATUS_CODE, + conftest.NOT_FOUND_RESPONSE, + conftest.NOT_FOUND_ERROR_MESSAGE, + ), ], ) @responses.activate @@ -241,8 +286,7 @@ def test_delete_should_raise_api_errors( expected_error_message: str, ) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" - responses.add( - responses.DELETE, + responses.delete( url, status=status_code, json=response_json, @@ -256,8 +300,7 @@ def test_delete_should_raise_api_errors( @responses.activate def test_delete_should_return_deleted_object(self, client: ProjectsApi) -> None: url = f"{BASE_PROJECTS_URL}/{PROJECT_ID}" - responses.add( - responses.DELETE, + responses.delete( url, json={"id": PROJECT_ID}, status=200, diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index bcb4bd0..e3bee20 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -152,7 +152,7 @@ def api_client(self): def test_headers_should_return_appropriate_dict( self, api_client: mt.MailtrapApiClient ) -> None: - assert api_client.get_headers() == { + assert api_client.headers == { "Authorization": "Bearer fake_token", "Content-Type": "application/json", "User-Agent": ( From 57a5452e6c63f5c64f5d88cdfd0223b0381dcad4 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 13 Aug 2025 01:44:30 +0300 Subject: [PATCH 10/13] Fix issue #29: Start using pydantic.dataclasses instead of simple dataclasses --- examples/testing/projects.py | 7 +++---- mailtrap/__init__.py | 1 - mailtrap/client.py | 35 +++++++++++++++------------------ mailtrap/config.py | 2 +- mailtrap/models/common.py | 5 +++-- mailtrap/models/inboxes.py | 5 +++-- mailtrap/models/permissions.py | 5 +++-- mailtrap/models/projects.py | 12 +++++++---- pyproject.toml | 8 ++++---- tests/unit/api/test_projects.py | 6 +++--- tests/unit/test_client.py | 24 +++++++--------------- 11 files changed, 51 insertions(+), 59 deletions(-) diff --git a/examples/testing/projects.py b/examples/testing/projects.py index 2822787..06531df 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -1,12 +1,11 @@ -from mailtrap import MailtrapApiClient +from mailtrap import MailtrapClient from mailtrap.models.projects import Project API_TOKEN = "YOU_API_TOKEN" ACCOUNT_ID = "YOU_ACCOUNT_ID" -client = MailtrapApiClient(token=API_TOKEN) -testing_api = client.testing_api(ACCOUNT_ID) -projects_api = testing_api.projects +client = MailtrapClient(token=API_TOKEN, account_id=ACCOUNT_ID) +projects_api = client.testing_api.projects def list_projects() -> list[Project]: diff --git a/mailtrap/__init__.py b/mailtrap/__init__.py index 055053e..f03b693 100644 --- a/mailtrap/__init__.py +++ b/mailtrap/__init__.py @@ -1,4 +1,3 @@ -from .client import MailtrapApiClient from .client import MailtrapClient from .exceptions import APIError from .exceptions import AuthorizationError diff --git a/mailtrap/client.py b/mailtrap/client.py index e2c644c..b2402b2 100644 --- a/mailtrap/client.py +++ b/mailtrap/client.py @@ -1,11 +1,12 @@ from typing import NoReturn from typing import Optional from typing import Union +from typing import cast import requests from mailtrap.api.testing import TestingApi -from mailtrap.config import GENERAL_ENDPOINT +from mailtrap.config import GENERAL_HOST from mailtrap.exceptions import APIError from mailtrap.exceptions import AuthorizationError from mailtrap.exceptions import ClientConfigurationError @@ -26,6 +27,7 @@ def __init__( api_port: int = DEFAULT_PORT, bulk: bool = False, sandbox: bool = False, + account_id: Optional[str] = None, inbox_id: Optional[str] = None, ) -> None: self.token = token @@ -33,10 +35,20 @@ def __init__( self.api_port = api_port self.bulk = bulk self.sandbox = sandbox + self.account_id = account_id self.inbox_id = inbox_id self._validate_itself() + @property + def testing_api(self) -> TestingApi: + self._validate_account_id() + return TestingApi( + account_id=cast(str, self.account_id), + inbox_id=self.inbox_id, + client=HttpClient(host=GENERAL_HOST, headers=self.headers), + ) + def send(self, mail: BaseMail) -> dict[str, Union[bool, list[str]]]: response = requests.post( self.api_send_url, headers=self.headers, json=mail.api_data @@ -102,21 +114,6 @@ def _validate_itself(self) -> None: if self.bulk and self.sandbox: raise ClientConfigurationError("bulk mode is not allowed in sandbox mode") - -class MailtrapApiClient: - def __init__(self, token: str) -> None: - self.token = token - - def testing_api(self, account_id: str, inbox_id: Optional[str] = None) -> TestingApi: - http_client = HttpClient(host=GENERAL_ENDPOINT, headers=self.headers) - return TestingApi(account_id=account_id, inbox_id=inbox_id, client=http_client) - - @property - def headers(self) -> dict[str, str]: - return { - "Authorization": f"Bearer {self.token}", - "Content-Type": "application/json", - "User-Agent": ( - "mailtrap-python (https://github.com/railsware/mailtrap-python)" - ), - } + def _validate_account_id(self) -> None: + if not self.account_id: + raise ClientConfigurationError("`account_id` is required for Testing API") diff --git a/mailtrap/config.py b/mailtrap/config.py index e08a1d4..c8a0527 100644 --- a/mailtrap/config.py +++ b/mailtrap/config.py @@ -1,3 +1,3 @@ -GENERAL_ENDPOINT = "mailtrap.io" +GENERAL_HOST = "mailtrap.io" DEFAULT_REQUEST_TIMEOUT = 30 # in seconds diff --git a/mailtrap/models/common.py b/mailtrap/models/common.py index 01df9ee..2388974 100644 --- a/mailtrap/models/common.py +++ b/mailtrap/models/common.py @@ -1,5 +1,6 @@ -from pydantic import BaseModel +from pydantic.dataclasses import dataclass -class DeletedObject(BaseModel): +@dataclass +class DeletedObject: id: int diff --git a/mailtrap/models/inboxes.py b/mailtrap/models/inboxes.py index e21d743..06d5374 100644 --- a/mailtrap/models/inboxes.py +++ b/mailtrap/models/inboxes.py @@ -1,11 +1,12 @@ from typing import Optional -from pydantic import BaseModel +from pydantic.dataclasses import dataclass from mailtrap.models.permissions import Permissions -class Inbox(BaseModel): +@dataclass +class Inbox: id: int name: str username: str diff --git a/mailtrap/models/permissions.py b/mailtrap/models/permissions.py index f36ef72..30b1e74 100644 --- a/mailtrap/models/permissions.py +++ b/mailtrap/models/permissions.py @@ -1,7 +1,8 @@ -from pydantic import BaseModel +from pydantic.dataclasses import dataclass -class Permissions(BaseModel): +@dataclass +class Permissions: can_read: bool can_update: bool can_destroy: bool diff --git a/mailtrap/models/projects.py b/mailtrap/models/projects.py index ce3baf8..20ecec6 100644 --- a/mailtrap/models/projects.py +++ b/mailtrap/models/projects.py @@ -1,17 +1,21 @@ -from pydantic import BaseModel +from typing import Optional + +from pydantic.dataclasses import dataclass from mailtrap.models.inboxes import Inbox from mailtrap.models.permissions import Permissions -class ShareLinks(BaseModel): +@dataclass +class ShareLinks: admin: str viewer: str -class Project(BaseModel): +@dataclass +class Project: id: int name: str - share_links: ShareLinks inboxes: list[Inbox] permissions: Permissions + share_links: Optional[ShareLinks] = None diff --git a/pyproject.toml b/pyproject.toml index f5cbc77..64fe111 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,10 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] requires-python = ">=3.9" -dependencies = [ - "requests>=2.26.0", - "pydantic>=2.11.7", -] +dynamic = ["dependencies"] [project.urls] Homepage = "https://mailtrap.io/" @@ -32,6 +29,9 @@ Repository = "https://github.com/railsware/mailtrap-python.git" requires = ["setuptools"] build-backend = "setuptools.build_meta" +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + [tool.isort] profile = "black" line_length = 90 diff --git a/tests/unit/api/test_projects.py b/tests/unit/api/test_projects.py index f2b374d..6ff42c0 100644 --- a/tests/unit/api/test_projects.py +++ b/tests/unit/api/test_projects.py @@ -4,7 +4,7 @@ import responses from mailtrap.api.resources.projects import ProjectsApi -from mailtrap.config import GENERAL_ENDPOINT +from mailtrap.config import GENERAL_HOST from mailtrap.exceptions import APIError from mailtrap.http import HttpClient from mailtrap.models.common import DeletedObject @@ -13,12 +13,12 @@ ACCOUNT_ID = "321" PROJECT_ID = 123 -BASE_PROJECTS_URL = f"https://{GENERAL_ENDPOINT}/api/accounts/{ACCOUNT_ID}/projects" +BASE_PROJECTS_URL = f"https://{GENERAL_HOST}/api/accounts/{ACCOUNT_ID}/projects" @pytest.fixture def client() -> ProjectsApi: - return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_ENDPOINT)) + return ProjectsApi(account_id=ACCOUNT_ID, client=HttpClient(GENERAL_HOST)) @pytest.fixture diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e3bee20..cc19c36 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -42,6 +42,13 @@ def test_client_validation(self, arguments: dict[str, Any]) -> None: with pytest.raises(mt.ClientConfigurationError): self.get_client(**arguments) + def test_get_testing_api_validation(self) -> None: + client = self.get_client() + with pytest.raises(mt.ClientConfigurationError) as exc_info: + client.testing_api + + assert "`account_id` is required for Testing API" in str(exc_info.value) + def test_base_url_should_truncate_slash_from_host(self) -> None: client = self.get_client(api_host="example.send.com/", api_port=543) @@ -142,20 +149,3 @@ def test_send_should_raise_api_error_for_500_status_code( with pytest.raises(mt.APIError): client.send(mail) - - -class TestMailtrapApiClient: - @pytest.fixture - def api_client(self): - return mt.MailtrapApiClient(token="fake_token") - - def test_headers_should_return_appropriate_dict( - self, api_client: mt.MailtrapApiClient - ) -> None: - assert api_client.headers == { - "Authorization": "Bearer fake_token", - "Content-Type": "application/json", - "User-Agent": ( - "mailtrap-python (https://github.com/railsware/mailtrap-python)" - ), - } From 20826ef5145dd9587da29d6ea975f6cc21ec523c Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Wed, 13 Aug 2025 16:00:33 +0300 Subject: [PATCH 11/13] Fix issue #29: make attributes as private in api classes --- examples/testing/projects.py | 5 +++++ mailtrap/api/resources/projects.py | 22 +++++++++++----------- mailtrap/api/testing.py | 8 ++++---- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/examples/testing/projects.py b/examples/testing/projects.py index 06531df..c7d192d 100644 --- a/examples/testing/projects.py +++ b/examples/testing/projects.py @@ -22,3 +22,8 @@ def update_project(project_id: str, new_name: str) -> Project: def delete_project(project_id: str): return projects_api.delete(project_id) + + +if __name__ == "__main__": + projects = list_projects() + print(projects) diff --git a/mailtrap/api/resources/projects.py b/mailtrap/api/resources/projects.py index 5faaef8..7df38d4 100644 --- a/mailtrap/api/resources/projects.py +++ b/mailtrap/api/resources/projects.py @@ -5,35 +5,35 @@ class ProjectsApi: def __init__(self, client: HttpClient, account_id: str) -> None: - self.account_id = account_id - self.client = client + self._account_id = account_id + self._client = client def get_list(self) -> list[Project]: - response = self.client.get(f"/api/accounts/{self.account_id}/projects") + response = self._client.get(f"/api/accounts/{self._account_id}/projects") return [Project(**project) for project in response] def get_by_id(self, project_id: int) -> Project: - response = self.client.get( - f"/api/accounts/{self.account_id}/projects/{project_id}" + response = self._client.get( + f"/api/accounts/{self._account_id}/projects/{project_id}" ) return Project(**response) def create(self, project_name: str) -> Project: - response = self.client.post( - f"/api/accounts/{self.account_id}/projects", + response = self._client.post( + f"/api/accounts/{self._account_id}/projects", json={"project": {"name": project_name}}, ) return Project(**response) def update(self, project_id: int, project_name: str) -> Project: - response = self.client.patch( - f"/api/accounts/{self.account_id}/projects/{project_id}", + response = self._client.patch( + f"/api/accounts/{self._account_id}/projects/{project_id}", json={"project": {"name": project_name}}, ) return Project(**response) def delete(self, project_id: int) -> DeletedObject: - response = self.client.delete( - f"/api/accounts/{self.account_id}/projects/{project_id}", + response = self._client.delete( + f"/api/accounts/{self._account_id}/projects/{project_id}", ) return DeletedObject(**response) diff --git a/mailtrap/api/testing.py b/mailtrap/api/testing.py index a8ca3e3..1f6fda1 100644 --- a/mailtrap/api/testing.py +++ b/mailtrap/api/testing.py @@ -8,10 +8,10 @@ class TestingApi: def __init__( self, client: HttpClient, account_id: str, inbox_id: Optional[str] = None ) -> None: - self.account_id = account_id - self.inbox_id = inbox_id - self.client = client + self._account_id = account_id + self._inbox_id = inbox_id + self._client = client @property def projects(self) -> ProjectsApi: - return ProjectsApi(account_id=self.account_id, client=self.client) + return ProjectsApi(account_id=self._account_id, client=self._client) From 2e5d0b2050492c3599e3e26591847df9f102fa2b Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Thu, 14 Aug 2025 13:31:58 +0300 Subject: [PATCH 12/13] Fix issue #29: Update README.md file --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6e56736..9972809 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Quickly add email sending functionality to your Python application with Mailtrap ## Compatibility with previous releases -Versions of this package up to 1.0.1 were a different, unrelated project, that is now maintained as [Sendria](https://github.com/msztolcman/sendria). To continue using it, see [instructions](#information-for-version-1-users). +Versions of this package up to 1.0.1 were different, unrelated project, that is now maintained as [Sendria](https://github.com/msztolcman/sendria). To continue using it, see [instructions](#information-for-version-1-users). ## Installation From 0a1a7677b01bba87df8ca8c96d6f7feec60390d0 Mon Sep 17 00:00:00 2001 From: Ihor Bilous Date: Thu, 14 Aug 2025 13:39:20 +0300 Subject: [PATCH 13/13] Fix issue #29: Update README.md description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9972809..b884138 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This Python package offers integration with the [official API](https://api-docs.mailtrap.io/) for [Mailtrap](https://mailtrap.io). -Quickly add email sending functionality to your Python application with Mailtrap. +Add email sending functionality to your Python application quickly with Mailtrap. ## Compatibility with previous releases