From 3933279db8f94e0f7d5c7972451f51732f2c5c67 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 00:06:16 -0800 Subject: [PATCH 01/16] Add module level null log handler --- jmapc/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/jmapc/__init__.py b/jmapc/__init__.py index 8709de1..74a8bb2 100644 --- a/jmapc/__init__.py +++ b/jmapc/__init__.py @@ -1,3 +1,5 @@ +import logging + from . import errors, methods from .client import Client from .errors import Error @@ -37,3 +39,6 @@ "errors", "methods", ] + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) From 0e2178e741eb8004c7ccd6a086fb3906d354ca69 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 07:45:51 -0800 Subject: [PATCH 02/16] Add debug logs for JMAP request and response content --- jmapc/client.py | 3 +++ tests/conftest.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/jmapc/client.py b/jmapc/client.py index a040409..e2a9a93 100644 --- a/jmapc/client.py +++ b/jmapc/client.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast import requests @@ -113,6 +114,7 @@ def _parse_responses(self, data: dict[str, Any]) -> MethodResponseList: return responses def _api_call(self, call: Any) -> Any: + logging.debug(f"Sending JMAP request {json.dumps(call)}") r = requests.post( self.session.api_url, auth=(self._user, self._password), @@ -120,4 +122,5 @@ def _api_call(self, call: Any) -> Any: data=json.dumps(call), ) r.raise_for_status() + logging.debug(f"Received JMAP response {r.text}") return self._parse_responses(r.json()) diff --git a/tests/conftest.py b/tests/conftest.py index 4744f28..9e6718d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ import json +import logging +import time from typing import Iterable import pytest @@ -7,6 +9,23 @@ from jmapc import Client +@pytest.fixture(autouse=True) +def test_log() -> Iterable[None]: + class UTCFormatter(logging.Formatter): + converter = time.gmtime + + logger = logging.getLogger() + handler = logging.StreamHandler() + formatter = UTCFormatter( + "%(asctime)s %(name)-12s %(levelname)-8s " + "[%(filename)s:%(funcName)s:%(lineno)d] %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + yield + + @pytest.fixture def client() -> Iterable[Client]: yield Client( From e5507495e0b37080fbee333b9c457b333da61442 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 08:19:37 -0800 Subject: [PATCH 03/16] Test all client method execution functions --- tests/test_client.py | 122 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index ffecc1f..25273b3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,8 +1,19 @@ +import json +from typing import Dict, Tuple + +import pytest +import requests import responses from jmapc import Client +from jmapc.client import MethodList +from jmapc.methods import CoreEcho, CoreEchoResponse from jmapc.session import Session, SessionPrimaryAccount +echo_test_data = dict( + who="Ness", goods=["Mr. Saturn coin", "Hall of Fame Bat"] +) + def test_session( client: Client, http_responses: responses.RequestsMock @@ -16,3 +27,114 @@ def test_session( submission="u1138", ), ) + + +def test_call_method( + client: Client, http_responses: responses.RequestsMock +) -> None: + def _response( + request: requests.PreparedRequest, + ) -> Tuple[int, Dict[str, str], str]: + assert request.headers["Content-Type"] == "application/json" + assert json.loads(request.body or "{}") == { + "methodCalls": [ + [ + "Core/echo", + echo_test_data, + "uno", + ], + ], + "using": ["urn:ietf:params:jmap:core"], + } + return ( + 200, + dict(), + json.dumps( + { + "methodResponses": [ + [ + "Core/echo", + echo_test_data, + "uno", + ], + ], + }, + ), + ) + + http_responses.add_callback( + method=responses.POST, + url="https://jmap-api.localhost/api", + callback=_response, + ) + assert client.call_method( + CoreEcho(data=echo_test_data) + ) == CoreEchoResponse(data=echo_test_data) + + +@pytest.mark.parametrize( + "method_params", + [ + [CoreEcho(data=echo_test_data), CoreEcho(data=echo_test_data)], + [ + ("0", CoreEcho(data=echo_test_data)), + ("1", CoreEcho(data=echo_test_data)), + ], + ], + ids=["methods_only", "custom_ids"], +) +def test_call_methods( + client: Client, + http_responses: responses.RequestsMock, + method_params: MethodList, +) -> None: + def _response( + request: requests.PreparedRequest, + ) -> Tuple[int, Dict[str, str], str]: + assert request.headers["Content-Type"] == "application/json" + assert json.loads(request.body or "{}") == { + "methodCalls": [ + [ + "Core/echo", + echo_test_data, + "0", + ], + [ + "Core/echo", + echo_test_data, + "1", + ], + ], + "using": ["urn:ietf:params:jmap:core"], + } + return ( + 200, + dict(), + json.dumps( + { + "methodResponses": [ + [ + "Core/echo", + echo_test_data, + "0", + ], + [ + "Core/echo", + echo_test_data, + "1", + ], + ], + }, + ), + ) + + http_responses.add_callback( + method=responses.POST, + url="https://jmap-api.localhost/api", + callback=_response, + ) + expected_response = CoreEchoResponse(data=echo_test_data) + assert client.call_methods(method_params) == [ + ("0", expected_response), + ("1", expected_response), + ] From 25f018394ca4f27bad2d2c38b41cbc1acd491626 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 08:24:28 -0800 Subject: [PATCH 04/16] Add HTTP error exception test --- tests/test_client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 25273b3..45ab634 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -138,3 +138,16 @@ def _response( ("0", expected_response), ("1", expected_response), ] + + +def test_error_unauthorized( + client: Client, http_responses: responses.RequestsMock +) -> None: + http_responses.add( + method=responses.POST, + url="https://jmap-api.localhost/api", + status=401, + ) + with pytest.raises(requests.exceptions.HTTPError) as e: + client.call_method(CoreEcho(data=echo_test_data)) + assert e.value.response.status_code == 401 From 9ccb68b5f2be95ba5a060ffde3c9aea8f12ff923 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:06:11 -0800 Subject: [PATCH 05/16] Add request and response test helpers --- jmapc/client.py | 4 +- jmapc/methods/identity.py | 5 +- tests/conftest.py | 2 + tests/methods/__init__.py | 0 tests/methods/test_core.py | 43 +++++------ tests/methods/test_identity.py | 116 ++++++++++++----------------- tests/test_client.py | 131 +++++++++++++-------------------- tests/utils.py | 34 +++++++++ 8 files changed, 163 insertions(+), 172 deletions(-) create mode 100644 tests/methods/__init__.py create mode 100644 tests/utils.py diff --git a/jmapc/client.py b/jmapc/client.py index e2a9a93..ac24ecd 100644 --- a/jmapc/client.py +++ b/jmapc/client.py @@ -62,7 +62,7 @@ def call_method(self, call: Method) -> Any: using = list(set([constants.JMAP_URN_CORE]).union(call.using())) result = self._api_call( { - "using": using, + "using": sorted(using), "methodCalls": [ [ call.name(), @@ -86,7 +86,7 @@ def call_methods(self, calls: Union[list[Method], MethodList]) -> Any: ) return self._api_call( { - "using": using, + "using": sorted(using), "methodCalls": [ [ c[1].name(), diff --git a/jmapc/methods/identity.py b/jmapc/methods/identity.py index 75ae75d..5a0a4b3 100644 --- a/jmapc/methods/identity.py +++ b/jmapc/methods/identity.py @@ -1,12 +1,13 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import List +from typing import List, Optional from dataclasses_json import config from .. import constants from ..models import Identity +from ..serializer import ListOrRef from .methods import Get, GetResponse @@ -20,6 +21,8 @@ def name(cls) -> str: def using(cls) -> set[str]: return set([constants.JMAP_URN_SUBMISSION]) + ids: Optional[ListOrRef[str]] = None + @dataclass class IdentityGetResponse(GetResponse): diff --git a/tests/conftest.py b/tests/conftest.py index 9e6718d..db73535 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,8 @@ from jmapc import Client +pytest.register_assert_rewrite("tests.utils") + @pytest.fixture(autouse=True) def test_log() -> Iterable[None]: diff --git a/tests/methods/__init__.py b/tests/methods/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/methods/test_core.py b/tests/methods/test_core.py index 8073130..cc7e750 100644 --- a/tests/methods/test_core.py +++ b/tests/methods/test_core.py @@ -1,34 +1,35 @@ -import json - import responses from jmapc import Client from jmapc.methods import CoreEcho, CoreEchoResponse +from ..utils import expect_jmap_call + def test_core_echo( client: Client, http_responses: responses.RequestsMock ) -> None: - http_responses.add( - method=responses.POST, - url="https://jmap-api.localhost/api", - body=json.dumps( - { - "methodResponses": [ - [ - "Core/echo", - { - "param1": "yes", - "another_param": "ok", - }, - "echo_base", - ], - ], - }, - ), - ) - test_data = dict(param1="yes", another_param="ok") + expected_request = { + "methodCalls": [ + [ + "Core/echo", + test_data, + "uno", + ], + ], + "using": ["urn:ietf:params:jmap:core"], + } + response = { + "methodResponses": [ + [ + "Core/echo", + test_data, + "uno", + ], + ], + } + expect_jmap_call(http_responses, expected_request, response) echo = CoreEcho(data=test_data) assert echo.to_dict() == test_data resp = client.call_method(echo) diff --git a/tests/methods/test_identity.py b/tests/methods/test_identity.py index 5b57f8e..db6a320 100644 --- a/tests/methods/test_identity.py +++ b/tests/methods/test_identity.py @@ -1,79 +1,61 @@ -import json - import responses -from jmapc import Client, Identity, ResultReference -from jmapc.client import MethodList +from jmapc import Client, Identity from jmapc.methods import IdentityGet, IdentityGetResponse +from ..utils import expect_jmap_call + def test_identity_get( client: Client, http_responses: responses.RequestsMock ) -> None: - http_responses.add( - method=responses.POST, - url="https://jmap-api.localhost/api", - body=json.dumps( - { - "methodResponses": [ - [ - "Identity/get", + response = { + "methodResponses": [ + [ + "Identity/get", + { + "accountId": "u1138", + "list": [ { - "accountId": "u1138", - "list": [ - { - "bcc": None, - "email": "ness@onett.example.net", - "htmlSignature": "", - "id": "0001", - "mayDelete": False, - "name": "Ness", - "replyTo": None, - "textSignature": "", - }, - ], - "not_found": [], - "state": "2187", + "bcc": None, + "email": "ness@onett.example.net", + "htmlSignature": "", + "id": "0001", + "mayDelete": False, + "name": "Ness", + "replyTo": None, + "textSignature": "", }, - "0", ], - ], - }, - ), + "not_found": [], + "state": "2187", + }, + "uno", + ] + ] + } + expected_request = { + "methodCalls": [["Identity/get", {"accountId": "u1138"}, "uno"]], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:submission", + ], + } + expect_jmap_call(http_responses, expected_request, response) + assert client.call_method(IdentityGet()) == IdentityGetResponse( + account_id="u1138", + state="2187", + not_found=[], + data=[ + Identity( + id="0001", + name="Ness", + email="ness@onett.example.net", + replyTo=None, + bcc=None, + textSignature="", + htmlSignature="", + mayDelete=False, + ) + ], ) - args: MethodList = [ - ("0", IdentityGet(ids=None)), - ( - "1", - IdentityGet( - ids=ResultReference( - name=IdentityGet.name(), - path="/ids", - result_of="0", - ) - ), - ), - ] - resp = client.call_methods(args) - assert resp == [ - ( - "0", - IdentityGetResponse( - account_id="u1138", - state="2187", - not_found=[], - data=[ - Identity( - id="0001", - name="Ness", - email="ness@onett.example.net", - replyTo=None, - bcc=None, - textSignature="", - htmlSignature="", - mayDelete=False, - ) - ], - ), - ), - ] diff --git a/tests/test_client.py b/tests/test_client.py index 45ab634..0b91b6a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,6 +1,3 @@ -import json -from typing import Dict, Tuple - import pytest import requests import responses @@ -10,6 +7,8 @@ from jmapc.methods import CoreEcho, CoreEchoResponse from jmapc.session import Session, SessionPrimaryAccount +from .utils import expect_jmap_call + echo_test_data = dict( who="Ness", goods=["Mr. Saturn coin", "Hall of Fame Bat"] ) @@ -32,41 +31,26 @@ def test_session( def test_call_method( client: Client, http_responses: responses.RequestsMock ) -> None: - def _response( - request: requests.PreparedRequest, - ) -> Tuple[int, Dict[str, str], str]: - assert request.headers["Content-Type"] == "application/json" - assert json.loads(request.body or "{}") == { - "methodCalls": [ - [ - "Core/echo", - echo_test_data, - "uno", - ], + expected_request = { + "methodCalls": [ + [ + "Core/echo", + echo_test_data, + "uno", ], - "using": ["urn:ietf:params:jmap:core"], - } - return ( - 200, - dict(), - json.dumps( - { - "methodResponses": [ - [ - "Core/echo", - echo_test_data, - "uno", - ], - ], - }, - ), - ) - - http_responses.add_callback( - method=responses.POST, - url="https://jmap-api.localhost/api", - callback=_response, - ) + ], + "using": ["urn:ietf:params:jmap:core"], + } + response = { + "methodResponses": [ + [ + "Core/echo", + echo_test_data, + "uno", + ], + ], + } + expect_jmap_call(http_responses, expected_request, response) assert client.call_method( CoreEcho(data=echo_test_data) ) == CoreEchoResponse(data=echo_test_data) @@ -88,51 +72,36 @@ def test_call_methods( http_responses: responses.RequestsMock, method_params: MethodList, ) -> None: - def _response( - request: requests.PreparedRequest, - ) -> Tuple[int, Dict[str, str], str]: - assert request.headers["Content-Type"] == "application/json" - assert json.loads(request.body or "{}") == { - "methodCalls": [ - [ - "Core/echo", - echo_test_data, - "0", - ], - [ - "Core/echo", - echo_test_data, - "1", - ], + expected_request = { + "methodCalls": [ + [ + "Core/echo", + echo_test_data, + "0", ], - "using": ["urn:ietf:params:jmap:core"], - } - return ( - 200, - dict(), - json.dumps( - { - "methodResponses": [ - [ - "Core/echo", - echo_test_data, - "0", - ], - [ - "Core/echo", - echo_test_data, - "1", - ], - ], - }, - ), - ) - - http_responses.add_callback( - method=responses.POST, - url="https://jmap-api.localhost/api", - callback=_response, - ) + [ + "Core/echo", + echo_test_data, + "1", + ], + ], + "using": ["urn:ietf:params:jmap:core"], + } + response = { + "methodResponses": [ + [ + "Core/echo", + echo_test_data, + "0", + ], + [ + "Core/echo", + echo_test_data, + "1", + ], + ], + } + expect_jmap_call(http_responses, expected_request, response) expected_response = CoreEchoResponse(data=echo_test_data) assert client.call_methods(method_params) == [ ("0", expected_response), diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..8eee676 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,34 @@ +import functools +import json +from typing import Any, Callable, Dict, Tuple + +import requests +import responses + + +def assert_request_return_response( + expected_request: Dict[str, Any], + response: Dict[str, Any], +) -> Callable[[requests.PreparedRequest], Tuple[int, Dict[str, str], str]]: + def _response_callback( + expected_request: Dict[str, Any], + response: Dict[str, Any], + request: requests.PreparedRequest, + ) -> Tuple[int, Dict[str, str], str]: + assert request.headers["Content-Type"] == "application/json" + assert json.loads(request.body or "{}") == expected_request + return (200, dict(), json.dumps(response)) + + return functools.partial(_response_callback, expected_request, response) + + +def expect_jmap_call( + http_responses: responses.RequestsMock, + expected_request: Dict[str, Any], + response: Dict[str, Any], +) -> None: + http_responses.add_callback( + method=responses.POST, + url="https://jmap-api.localhost/api", + callback=assert_request_return_response(expected_request, response), + ) From 9d06250deb31fa0eb612e9fa11120b5c013dec84 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:07:22 -0800 Subject: [PATCH 06/16] Fix expected_request/response ordering in test_identity.py --- tests/methods/test_identity.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/methods/test_identity.py b/tests/methods/test_identity.py index db6a320..e42aaa2 100644 --- a/tests/methods/test_identity.py +++ b/tests/methods/test_identity.py @@ -9,6 +9,13 @@ def test_identity_get( client: Client, http_responses: responses.RequestsMock ) -> None: + expected_request = { + "methodCalls": [["Identity/get", {"accountId": "u1138"}, "uno"]], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:submission", + ], + } response = { "methodResponses": [ [ @@ -34,13 +41,6 @@ def test_identity_get( ] ] } - expected_request = { - "methodCalls": [["Identity/get", {"accountId": "u1138"}, "uno"]], - "using": [ - "urn:ietf:params:jmap:core", - "urn:ietf:params:jmap:submission", - ], - } expect_jmap_call(http_responses, expected_request, response) assert client.call_method(IdentityGet()) == IdentityGetResponse( account_id="u1138", From 94a7604dbb236c97a114aab770817e01c44e84dc Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:13:28 -0800 Subject: [PATCH 07/16] Add test for Thread/get --- tests/methods/test_thread.py | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/methods/test_thread.py diff --git a/tests/methods/test_thread.py b/tests/methods/test_thread.py new file mode 100644 index 0000000..1763517 --- /dev/null +++ b/tests/methods/test_thread.py @@ -0,0 +1,71 @@ +import responses + +from jmapc import Client, Thread +from jmapc.methods import ThreadGet, ThreadGetResponse + +from ..utils import expect_jmap_call + + +def test_identity_get( + client: Client, http_responses: responses.RequestsMock +) -> None: + expected_request = { + "methodCalls": [ + [ + "Thread/get", + {"accountId": "u1138", "ids": ["T1", "T1000"]}, + "uno", + ] + ], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + ], + } + response = { + "methodResponses": [ + [ + "Thread/get", + { + "accountId": "u1138", + "list": [ + { + "id": "T1", + "emailIds": [ + "M1234", + "M2345", + "M3456", + ], + }, + { + "id": "T1000", + "emailIds": [ + "M1001", + ], + }, + ], + "not_found": [], + "state": "2187", + }, + "uno", + ] + ] + } + expect_jmap_call(http_responses, expected_request, response) + assert client.call_method( + ThreadGet(ids=["T1", "T1000"]) + ) == ThreadGetResponse( + account_id="u1138", + state="2187", + not_found=[], + data=[ + Thread( + id="T1", + email_ids=["M1234", "M2345", "M3456"], + ), + Thread( + id="T1000", + email_ids=["M1001"], + ), + ], + ) From 3df929fd27cca218ca76f1c99b83794da8d93f2b Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:20:46 -0800 Subject: [PATCH 08/16] Add test for Mailbox/get --- jmapc/methods/email.py | 44 ++++++++--------- jmapc/methods/mailbox.py | 32 ++++++------- jmapc/models.py | 12 ++--- tests/methods/test_mailbox.py | 89 +++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 44 deletions(-) create mode 100644 tests/methods/test_mailbox.py diff --git a/jmapc/methods/email.py b/jmapc/methods/email.py index 48531e8..6dd8f50 100644 --- a/jmapc/methods/email.py +++ b/jmapc/methods/email.py @@ -18,6 +18,28 @@ from .methods import Get, GetResponse, Query, QueryResponse +@dataclass +class EmailGet(Get): + @classmethod + def name(cls) -> str: + return "Email/get" + + @classmethod + def using(cls) -> set[str]: + return set([constants.JMAP_URN_MAIL]) + + body_properties: Optional[List[str]] = None + fetch_text_body_values: Optional[bool] = None + fetch_html_body_values: Optional[bool] = None + fetch_all_body_values: Optional[bool] = None + max_body_value_bytes: Optional[int] = None + + +@dataclass +class EmailGetResponse(GetResponse): + data: List[Email] = field(metadata=config(field_name="list")) + + @dataclass class EmailQuery(Query): @classmethod @@ -75,25 +97,3 @@ class EmailQueryFilterOperator(Model): EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] - - -@dataclass -class EmailGet(Get): - @classmethod - def name(cls) -> str: - return "Email/get" - - @classmethod - def using(cls) -> set[str]: - return set([constants.JMAP_URN_MAIL]) - - body_properties: Optional[List[str]] = None - fetch_text_body_values: Optional[bool] = None - fetch_html_body_values: Optional[bool] = None - fetch_all_body_values: Optional[bool] = None - max_body_value_bytes: Optional[int] = None - - -@dataclass -class EmailGetResponse(GetResponse): - data: List[Email] = field(metadata=config(field_name="list")) diff --git a/jmapc/methods/mailbox.py b/jmapc/methods/mailbox.py index fd6f18b..c243ced 100644 --- a/jmapc/methods/mailbox.py +++ b/jmapc/methods/mailbox.py @@ -11,6 +11,22 @@ from .methods import Get, GetResponse, Query, QueryResponse +@dataclass +class MailboxGet(Get): + @classmethod + def name(cls) -> str: + return "Mailbox/get" + + @classmethod + def using(cls) -> set[str]: + return set([constants.JMAP_URN_MAIL]) + + +@dataclass +class MailboxGetResponse(GetResponse): + data: List[Mailbox] = field(metadata=config(field_name="list")) + + @dataclass class MailboxQuery(Query): @classmethod @@ -45,19 +61,3 @@ class MailboxQueryFilterOperator(Model): MailboxQueryFilter = Union[ MailboxQueryFilterCondition, MailboxQueryFilterOperator ] - - -@dataclass -class MailboxGet(Get): - @classmethod - def name(cls) -> str: - return "Mailbox/get" - - @classmethod - def using(cls) -> set[str]: - return set([constants.JMAP_URN_MAIL]) - - -@dataclass -class MailboxGetResponse(GetResponse): - data: List[Mailbox] = field(metadata=config(field_name="list")) diff --git a/jmapc/models.py b/jmapc/models.py index f450b80..91b08f7 100644 --- a/jmapc/models.py +++ b/jmapc/models.py @@ -25,12 +25,12 @@ class Identity(Model): class Mailbox(Model): id: str = field(metadata=config(field_name="Id")) name: str - sort_order: int = field(metadata=config(field_name="sortOrder")) - total_emails: int = field(metadata=config(field_name="totalEmails")) - unread_emails: int = field(metadata=config(field_name="unreadEmails")) - total_threads: int = field(metadata=config(field_name="totalThreads")) - unread_threads: int = field(metadata=config(field_name="unreadThreads")) - is_subsribed: bool = field(metadata=config(field_name="isSubscribed")) + sort_order: int + total_emails: int + unread_emails: int + total_threads: int + unread_threads: int + is_subscribed: bool role: Optional[str] = None parent_id: Optional[str] = field( metadata=config(field_name="parentId"), default=None diff --git a/tests/methods/test_mailbox.py b/tests/methods/test_mailbox.py new file mode 100644 index 0000000..e878792 --- /dev/null +++ b/tests/methods/test_mailbox.py @@ -0,0 +1,89 @@ +import responses + +from jmapc import Client, Mailbox +from jmapc.methods import MailboxGet, MailboxGetResponse + +from ..utils import expect_jmap_call + + +def test_identity_get( + client: Client, http_responses: responses.RequestsMock +) -> None: + expected_request = { + "methodCalls": [ + [ + "Mailbox/get", + {"accountId": "u1138", "ids": ["MBX1", "MBX1000"]}, + "uno", + ] + ], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + ], + } + response = { + "methodResponses": [ + [ + "Mailbox/get", + { + "accountId": "u1138", + "list": [ + { + "id": "MBX1", + "name": "First", + "sortOrder": 1, + "totalEmails": 100, + "unreadEmails": 3, + "totalThreads": 5, + "unreadThreads": 1, + "isSubscribed": True, + }, + { + "id": "MBX1000", + "name": "More Mailbox", + "sortOrder": 42, + "totalEmails": 10000, + "unreadEmails": 99, + "totalThreads": 5000, + "unreadThreads": 90, + "isSubscribed": False, + }, + ], + "not_found": [], + "state": "2187", + }, + "uno", + ] + ] + } + expect_jmap_call(http_responses, expected_request, response) + assert client.call_method( + MailboxGet(ids=["MBX1", "MBX1000"]) + ) == MailboxGetResponse( + account_id="u1138", + state="2187", + not_found=[], + data=[ + Mailbox( + id="MBX1", + name="First", + sort_order=1, + total_emails=100, + unread_emails=3, + total_threads=5, + unread_threads=1, + is_subscribed=True, + ), + Mailbox( + id="MBX1000", + name="More Mailbox", + sort_order=42, + total_emails=10000, + unread_emails=99, + total_threads=5000, + unread_threads=90, + is_subscribed=False, + ), + ], + ) From e91c16521bc55ccad1944d6b30696cee77f7a825 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:35:45 -0800 Subject: [PATCH 09/16] Add test for Email/get --- jmapc/methods/email.py | 4 +- jmapc/models.py | 1 + tests/methods/test_email.py | 107 ++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/methods/test_email.py diff --git a/jmapc/methods/email.py b/jmapc/methods/email.py index 6dd8f50..831c65a 100644 --- a/jmapc/methods/email.py +++ b/jmapc/methods/email.py @@ -30,7 +30,9 @@ def using(cls) -> set[str]: body_properties: Optional[List[str]] = None fetch_text_body_values: Optional[bool] = None - fetch_html_body_values: Optional[bool] = None + fetch_html_body_values: Optional[bool] = field( + metadata=config(field_name="fetchHTMLBodyValues"), default=None + ) fetch_all_body_values: Optional[bool] = None max_body_value_bytes: Optional[int] = None diff --git a/jmapc/models.py b/jmapc/models.py index 91b08f7..24694ba 100644 --- a/jmapc/models.py +++ b/jmapc/models.py @@ -59,6 +59,7 @@ class Email(Model): to: Optional[List[EmailAddress]] = None cc: Optional[List[EmailAddress]] = None bcc: Optional[List[EmailAddress]] = None + reply_to: Optional[List[EmailAddress]] = None subject: Optional[str] = None sent_at: Optional[datetime] = field( default=None, diff --git a/tests/methods/test_email.py b/tests/methods/test_email.py new file mode 100644 index 0000000..75398fd --- /dev/null +++ b/tests/methods/test_email.py @@ -0,0 +1,107 @@ +from datetime import datetime, timezone + +import responses + +from jmapc import Client, Email, EmailAddress +from jmapc.methods import EmailGet, EmailGetResponse + +from ..utils import expect_jmap_call + + +def test_identity_get( + client: Client, http_responses: responses.RequestsMock +) -> None: + expected_request = { + "methodCalls": [ + [ + "Email/get", + { + "accountId": "u1138", + "ids": ["f0001", "f1000"], + "fetchTextBodyValues": False, + "fetchHTMLBodyValues": False, + "fetchAllBodyValues": False, + "maxBodyValueBytes": 42, + }, + "uno", + ] + ], + "using": [ + "urn:ietf:params:jmap:core", + "urn:ietf:params:jmap:mail", + ], + } + response = { + "methodResponses": [ + [ + "Email/get", + { + "accountId": "u1138", + "list": [ + { + "id": "f0001", + "threadId": "T1", + "mailboxIds": { + "MBX1": True, + "MBX5": True, + }, + "from": [ + { + "name": "Paula", + "email": "paula@twoson.example.net", + } + ], + "reply_to": [ + { + "name": "Paula", + "email": "paula-reply@twoson.example.net", + } + ], + "subject": ( + "I'm taking a day trip to Happy Happy Village" + ), + "receivedAt": "1994-08-24T12:01:02Z", + }, + ], + "not_found": [], + "state": "2187", + }, + "uno", + ] + ] + } + expect_jmap_call(http_responses, expected_request, response) + assert client.call_method( + EmailGet( + ids=["f0001", "f1000"], + fetch_text_body_values=False, + fetch_html_body_values=False, + fetch_all_body_values=False, + max_body_value_bytes=42, + ) + ) == EmailGetResponse( + account_id="u1138", + state="2187", + not_found=[], + data=[ + Email( + id="f0001", + thread_id="T1", + mailbox_ids={"MBX1": True, "MBX5": True}, + mail_from=[ + EmailAddress( + name="Paula", email="paula@twoson.example.net" + ), + ], + reply_to=[ + EmailAddress( + name="Paula", email="paula-reply@twoson.example.net" + ), + ], + subject="I'm taking a day trip to Happy Happy Village", + received_at=datetime( + 1994, 8, 24, 12, 1, 2, tzinfo=timezone.utc + ), + ), + ], + ) From 80a1b1122c14c6fed55469ff378bf399aa944965 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:44:19 -0800 Subject: [PATCH 10/16] Move ListOrRef and StrOrRef to models.py --- jmapc/__init__.py | 3 ++- jmapc/methods/email.py | 10 ++-------- jmapc/methods/identity.py | 3 +-- jmapc/methods/mailbox.py | 4 ++-- jmapc/methods/methods.py | 4 ++-- jmapc/models.py | 7 ++++++- jmapc/serializer.py | 6 +----- tests/test_serializer.py | 3 ++- 8 files changed, 18 insertions(+), 22 deletions(-) diff --git a/jmapc/__init__.py b/jmapc/__init__.py index 74a8bb2..30165c9 100644 --- a/jmapc/__init__.py +++ b/jmapc/__init__.py @@ -11,13 +11,14 @@ EmailBodyValue, EmailHeader, Identity, + ListOrRef, Mailbox, Operator, + StrOrRef, Thread, ThreadEmail, ) from .ref import ResultReference -from .serializer import ListOrRef, StrOrRef __all__ = [ "Client", diff --git a/jmapc/methods/email.py b/jmapc/methods/email.py index 831c65a..0db3f23 100644 --- a/jmapc/methods/email.py +++ b/jmapc/methods/email.py @@ -7,14 +7,8 @@ from dataclasses_json import config from .. import constants -from ..models import Email, Operator -from ..serializer import ( - ListOrRef, - Model, - StrOrRef, - datetime_decode, - datetime_encode, -) +from ..models import Email, ListOrRef, Operator, StrOrRef +from ..serializer import Model, datetime_decode, datetime_encode from .methods import Get, GetResponse, Query, QueryResponse diff --git a/jmapc/methods/identity.py b/jmapc/methods/identity.py index 5a0a4b3..600127f 100644 --- a/jmapc/methods/identity.py +++ b/jmapc/methods/identity.py @@ -6,8 +6,7 @@ from dataclasses_json import config from .. import constants -from ..models import Identity -from ..serializer import ListOrRef +from ..models import Identity, ListOrRef from .methods import Get, GetResponse diff --git a/jmapc/methods/mailbox.py b/jmapc/methods/mailbox.py index c243ced..1ff3868 100644 --- a/jmapc/methods/mailbox.py +++ b/jmapc/methods/mailbox.py @@ -6,8 +6,8 @@ from dataclasses_json import config from .. import constants -from ..models import Mailbox, Operator -from ..serializer import ListOrRef, Model, StrOrRef +from ..models import ListOrRef, Mailbox, Operator, StrOrRef +from ..serializer import Model from .methods import Get, GetResponse, Query, QueryResponse diff --git a/jmapc/methods/methods.py b/jmapc/methods/methods.py index b2e005a..d9a697c 100644 --- a/jmapc/methods/methods.py +++ b/jmapc/methods/methods.py @@ -3,8 +3,8 @@ from dataclasses import dataclass, field from typing import List, Optional -from ..models import Comparator -from ..serializer import ListOrRef, Model +from ..models import Comparator, ListOrRef +from ..serializer import Model @dataclass diff --git a/jmapc/models.py b/jmapc/models.py index 24694ba..7ced93b 100644 --- a/jmapc/models.py +++ b/jmapc/models.py @@ -2,12 +2,17 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, List, Optional +from typing import Dict, List, Optional, TypeVar, Union from dataclasses_json import config +from .ref import ResultReference from .serializer import Model, datetime_decode, datetime_encode +T = TypeVar("T") +StrOrRef = Union[str, ResultReference] +ListOrRef = Union[ResultReference, List[T]] + @dataclass class Identity(Model): diff --git a/jmapc/serializer.py b/jmapc/serializer.py index cde242d..972e5c4 100644 --- a/jmapc/serializer.py +++ b/jmapc/serializer.py @@ -1,16 +1,12 @@ from dataclasses import fields from datetime import datetime -from typing import Any, Dict, List, Optional, TypeVar, Union +from typing import Any, Dict, Optional import dataclasses_json import dateutil.parser from .ref import ResultReference -T = TypeVar("T") -StrOrRef = Union[str, ResultReference] -ListOrRef = Union[ResultReference, List[T]] - def datetime_encode(dt: Optional[datetime]) -> Optional[str]: if not dt: diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 6924372..7834c4c 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -2,7 +2,8 @@ from typing import Optional from jmapc import ResultReference -from jmapc.serializer import ListOrRef, Model +from jmapc.models import ListOrRef +from jmapc.serializer import Model def test_camel_case() -> None: From 356d83db98c0a1e5ac86e2900596a2383b1fe6f1 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:46:34 -0800 Subject: [PATCH 11/16] Rename methods/methods.py to methods/base.py --- jmapc/methods/__init__.py | 2 +- jmapc/methods/{methods.py => base.py} | 0 jmapc/methods/core.py | 2 +- jmapc/methods/email.py | 2 +- jmapc/methods/identity.py | 2 +- jmapc/methods/mailbox.py | 2 +- jmapc/methods/thread.py | 2 +- 7 files changed, 6 insertions(+), 6 deletions(-) rename jmapc/methods/{methods.py => base.py} (100%) diff --git a/jmapc/methods/__init__.py b/jmapc/methods/__init__.py index c8eb52f..6d4a081 100644 --- a/jmapc/methods/__init__.py +++ b/jmapc/methods/__init__.py @@ -1,3 +1,4 @@ +from .base import Method, Response from .core import CoreEcho, CoreEchoResponse from .email import ( EmailGet, @@ -18,7 +19,6 @@ MailboxQueryFilterOperator, MailboxQueryResponse, ) -from .methods import Method, Response from .thread import ThreadGet, ThreadGetResponse __all__ = [ diff --git a/jmapc/methods/methods.py b/jmapc/methods/base.py similarity index 100% rename from jmapc/methods/methods.py rename to jmapc/methods/base.py diff --git a/jmapc/methods/core.py b/jmapc/methods/core.py index 24732a4..fd28495 100644 --- a/jmapc/methods/core.py +++ b/jmapc/methods/core.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import Any, Dict, Optional -from .methods import Method, Response +from .base import Method, Response @dataclass diff --git a/jmapc/methods/email.py b/jmapc/methods/email.py index 0db3f23..fac69b9 100644 --- a/jmapc/methods/email.py +++ b/jmapc/methods/email.py @@ -9,7 +9,7 @@ from .. import constants from ..models import Email, ListOrRef, Operator, StrOrRef from ..serializer import Model, datetime_decode, datetime_encode -from .methods import Get, GetResponse, Query, QueryResponse +from .base import Get, GetResponse, Query, QueryResponse @dataclass diff --git a/jmapc/methods/identity.py b/jmapc/methods/identity.py index 600127f..0aa39d0 100644 --- a/jmapc/methods/identity.py +++ b/jmapc/methods/identity.py @@ -7,7 +7,7 @@ from .. import constants from ..models import Identity, ListOrRef -from .methods import Get, GetResponse +from .base import Get, GetResponse @dataclass diff --git a/jmapc/methods/mailbox.py b/jmapc/methods/mailbox.py index 1ff3868..edc6f9a 100644 --- a/jmapc/methods/mailbox.py +++ b/jmapc/methods/mailbox.py @@ -8,7 +8,7 @@ from .. import constants from ..models import ListOrRef, Mailbox, Operator, StrOrRef from ..serializer import Model -from .methods import Get, GetResponse, Query, QueryResponse +from .base import Get, GetResponse, Query, QueryResponse @dataclass diff --git a/jmapc/methods/thread.py b/jmapc/methods/thread.py index ac62338..3417f63 100644 --- a/jmapc/methods/thread.py +++ b/jmapc/methods/thread.py @@ -7,7 +7,7 @@ from .. import constants from ..models import Thread -from .methods import Get, GetResponse +from .base import Get, GetResponse @dataclass From 4e43506559aedce56f88e8a43dd03855f2b0f529 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:49:24 -0800 Subject: [PATCH 12/16] Move email query filter models to models.py --- jmapc/__init__.py | 6 +++++ jmapc/methods/__init__.py | 13 +---------- jmapc/methods/email.py | 46 ++------------------------------------- jmapc/models.py | 40 ++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 56 deletions(-) diff --git a/jmapc/__init__.py b/jmapc/__init__.py index 30165c9..a0bcbb2 100644 --- a/jmapc/__init__.py +++ b/jmapc/__init__.py @@ -10,6 +10,9 @@ EmailBodyPart, EmailBodyValue, EmailHeader, + EmailQueryFilter, + EmailQueryFilterCondition, + EmailQueryFilterOperator, Identity, ListOrRef, Mailbox, @@ -28,6 +31,9 @@ "EmailBodyPart", "EmailBodyValue", "EmailHeader", + "EmailQueryFilter", + "EmailQueryFilterCondition", + "EmailQueryFilterOperator", "Error", "Identity", "ListOrRef", diff --git a/jmapc/methods/__init__.py b/jmapc/methods/__init__.py index 6d4a081..a1a19d6 100644 --- a/jmapc/methods/__init__.py +++ b/jmapc/methods/__init__.py @@ -1,14 +1,6 @@ from .base import Method, Response from .core import CoreEcho, CoreEchoResponse -from .email import ( - EmailGet, - EmailGetResponse, - EmailQuery, - EmailQueryFilter, - EmailQueryFilterCondition, - EmailQueryFilterOperator, - EmailQueryResponse, -) +from .email import EmailGet, EmailGetResponse, EmailQuery, EmailQueryResponse from .identity import IdentityGet, IdentityGetResponse from .mailbox import ( MailboxGet, @@ -27,9 +19,6 @@ "EmailGet", "EmailGetResponse", "EmailQuery", - "EmailQueryFilter", - "EmailQueryFilterCondition", - "EmailQueryFilterOperator", "EmailQueryResponse", "IdentityGet", "IdentityGetResponse", diff --git a/jmapc/methods/email.py b/jmapc/methods/email.py index fac69b9..e16ce17 100644 --- a/jmapc/methods/email.py +++ b/jmapc/methods/email.py @@ -1,14 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime -from typing import List, Optional, Union +from typing import List, Optional from dataclasses_json import config from .. import constants -from ..models import Email, ListOrRef, Operator, StrOrRef -from ..serializer import Model, datetime_decode, datetime_encode +from ..models import Email, EmailQueryFilter, ListOrRef from .base import Get, GetResponse, Query, QueryResponse @@ -53,43 +51,3 @@ def using(cls) -> set[str]: @dataclass class EmailQueryResponse(QueryResponse): ids: ListOrRef[str] - - -@dataclass -class EmailQueryFilterCondition(Model): - in_mailbox: Optional[StrOrRef] = None - in_mailbox_other_than: Optional[ListOrRef] = None - before: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - after: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - min_size: Optional[int] = None - max_size: Optional[int] = None - all_in_thread_have_keyword: Optional[StrOrRef] = None - some_in_thread_have_keyword: Optional[StrOrRef] = None - none_in_thread_have_keyword: Optional[StrOrRef] = None - has_keyword: Optional[StrOrRef] = None - not_keyword: Optional[StrOrRef] = None - has_attachment: Optional[bool] = None - text: Optional[StrOrRef] = None - mail_from: Optional[str] = field( - metadata=config(field_name="from"), default=None - ) - to: Optional[StrOrRef] = None - cc: Optional[StrOrRef] = None - bcc: Optional[StrOrRef] = None - body: Optional[StrOrRef] = None - header: Optional[ListOrRef] = None - - -@dataclass -class EmailQueryFilterOperator(Model): - operator: Operator - conditions: List[EmailQueryFilter] - - -EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] diff --git a/jmapc/models.py b/jmapc/models.py index 7ced93b..58f977b 100644 --- a/jmapc/models.py +++ b/jmapc/models.py @@ -152,3 +152,43 @@ class Operator: AND = "AND" OR = "OR" NOT = "NOT" + + +@dataclass +class EmailQueryFilterCondition(Model): + in_mailbox: Optional[StrOrRef] = None + in_mailbox_other_than: Optional[ListOrRef] = None + before: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + after: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + min_size: Optional[int] = None + max_size: Optional[int] = None + all_in_thread_have_keyword: Optional[StrOrRef] = None + some_in_thread_have_keyword: Optional[StrOrRef] = None + none_in_thread_have_keyword: Optional[StrOrRef] = None + has_keyword: Optional[StrOrRef] = None + not_keyword: Optional[StrOrRef] = None + has_attachment: Optional[bool] = None + text: Optional[StrOrRef] = None + mail_from: Optional[str] = field( + metadata=config(field_name="from"), default=None + ) + to: Optional[StrOrRef] = None + cc: Optional[StrOrRef] = None + bcc: Optional[StrOrRef] = None + body: Optional[StrOrRef] = None + header: Optional[ListOrRef] = None + + +@dataclass +class EmailQueryFilterOperator(Model): + operator: Operator + conditions: List[EmailQueryFilter] + + +EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] From 992a5f9c5d2272dc5dee6a3af4e8359fd5fb5f4c Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:51:14 -0800 Subject: [PATCH 13/16] Move mailbox query filter models to models.py --- jmapc/__init__.py | 6 ++++++ jmapc/methods/__init__.py | 6 ------ jmapc/methods/mailbox.py | 23 ++--------------------- jmapc/models.py | 18 ++++++++++++++++++ 4 files changed, 26 insertions(+), 27 deletions(-) diff --git a/jmapc/__init__.py b/jmapc/__init__.py index a0bcbb2..9bb6bbe 100644 --- a/jmapc/__init__.py +++ b/jmapc/__init__.py @@ -16,6 +16,9 @@ Identity, ListOrRef, Mailbox, + MailboxQueryFilter, + MailboxQueryFilterCondition, + MailboxQueryFilterOperator, Operator, StrOrRef, Thread, @@ -38,6 +41,9 @@ "Identity", "ListOrRef", "Mailbox", + "MailboxQueryFilter", + "MailboxQueryFilterCondition", + "MailboxQueryFilterOperator", "Operator", "ResultReference", "StrOrRef", diff --git a/jmapc/methods/__init__.py b/jmapc/methods/__init__.py index a1a19d6..8372bad 100644 --- a/jmapc/methods/__init__.py +++ b/jmapc/methods/__init__.py @@ -6,9 +6,6 @@ MailboxGet, MailboxGetResponse, MailboxQuery, - MailboxQueryFilter, - MailboxQueryFilterCondition, - MailboxQueryFilterOperator, MailboxQueryResponse, ) from .thread import ThreadGet, ThreadGetResponse @@ -25,9 +22,6 @@ "MailboxGet", "MailboxGetResponse", "MailboxQuery", - "MailboxQueryFilter", - "MailboxQueryFilterCondition", - "MailboxQueryFilterOperator", "MailboxQueryResponse", "Method", "Response", diff --git a/jmapc/methods/mailbox.py b/jmapc/methods/mailbox.py index edc6f9a..6b3f6b1 100644 --- a/jmapc/methods/mailbox.py +++ b/jmapc/methods/mailbox.py @@ -1,13 +1,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import List, Optional from dataclasses_json import config from .. import constants -from ..models import ListOrRef, Mailbox, Operator, StrOrRef -from ..serializer import Model +from ..models import ListOrRef, Mailbox, MailboxQueryFilter from .base import Get, GetResponse, Query, QueryResponse @@ -43,21 +42,3 @@ def using(cls) -> set[str]: @dataclass class MailboxQueryResponse(QueryResponse): ids: ListOrRef[str] - - -@dataclass -class MailboxQueryFilterCondition(Model): - name: Optional[StrOrRef] = None - role: Optional[StrOrRef] = None - parent_id: Optional[StrOrRef] = None - - -@dataclass -class MailboxQueryFilterOperator(Model): - operator: Operator - conditions: List[MailboxQueryFilter] - - -MailboxQueryFilter = Union[ - MailboxQueryFilterCondition, MailboxQueryFilterOperator -] diff --git a/jmapc/models.py b/jmapc/models.py index 58f977b..6f14512 100644 --- a/jmapc/models.py +++ b/jmapc/models.py @@ -192,3 +192,21 @@ class EmailQueryFilterOperator(Model): EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] + + +@dataclass +class MailboxQueryFilterCondition(Model): + name: Optional[StrOrRef] = None + role: Optional[StrOrRef] = None + parent_id: Optional[StrOrRef] = None + + +@dataclass +class MailboxQueryFilterOperator(Model): + operator: Operator + conditions: List[MailboxQueryFilter] + + +MailboxQueryFilter = Union[ + MailboxQueryFilterCondition, MailboxQueryFilterOperator +] From 9dfd3502c3be076c87f37087369e3485ea949144 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 09:55:28 -0800 Subject: [PATCH 14/16] Move models to a subdirectory --- jmapc/__init__.py | 3 ++- jmapc/models/__init__.py | 43 ++++++++++++++++++++++++++++++++++++ jmapc/{ => models}/models.py | 4 ++-- 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 jmapc/models/__init__.py rename jmapc/{ => models}/models.py (98%) diff --git a/jmapc/__init__.py b/jmapc/__init__.py index 9bb6bbe..c6b4f2a 100644 --- a/jmapc/__init__.py +++ b/jmapc/__init__.py @@ -1,6 +1,6 @@ import logging -from . import errors, methods +from . import errors, methods, models from .client import Client from .errors import Error from .models import ( @@ -51,6 +51,7 @@ "ThreadEmail", "errors", "methods", + "models", ] # Set default logging handler to avoid "No handler found" warnings. diff --git a/jmapc/models/__init__.py b/jmapc/models/__init__.py new file mode 100644 index 0000000..c065b6b --- /dev/null +++ b/jmapc/models/__init__.py @@ -0,0 +1,43 @@ +from .models import ( + Comparator, + Email, + EmailAddress, + EmailBodyPart, + EmailBodyValue, + EmailHeader, + EmailQueryFilter, + EmailQueryFilterCondition, + EmailQueryFilterOperator, + Identity, + ListOrRef, + Mailbox, + MailboxQueryFilter, + MailboxQueryFilterCondition, + MailboxQueryFilterOperator, + Operator, + StrOrRef, + Thread, + ThreadEmail, +) + +__all__ = [ + "Comparator", + "Email", + "EmailAddress", + "EmailBodyPart", + "EmailBodyValue", + "EmailHeader", + "EmailQueryFilter", + "EmailQueryFilterCondition", + "EmailQueryFilterOperator", + "Identity", + "ListOrRef", + "Mailbox", + "MailboxQueryFilter", + "MailboxQueryFilterCondition", + "MailboxQueryFilterOperator", + "Operator", + "StrOrRef", + "Thread", + "ThreadEmail", +] diff --git a/jmapc/models.py b/jmapc/models/models.py similarity index 98% rename from jmapc/models.py rename to jmapc/models/models.py index 6f14512..6399897 100644 --- a/jmapc/models.py +++ b/jmapc/models/models.py @@ -6,8 +6,8 @@ from dataclasses_json import config -from .ref import ResultReference -from .serializer import Model, datetime_decode, datetime_encode +from ..ref import ResultReference +from ..serializer import Model, datetime_decode, datetime_encode T = TypeVar("T") StrOrRef = Union[str, ResultReference] From 1f6a425b8e804562451bfaf2a903aded4c367c25 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 10:01:31 -0800 Subject: [PATCH 15/16] Organize models into multiple files --- jmapc/models/__init__.py | 15 ++-- jmapc/models/email.py | 116 +++++++++++++++++++++++++ jmapc/models/identity.py | 19 +++++ jmapc/models/mailbox.py | 43 ++++++++++ jmapc/models/models.py | 178 +-------------------------------------- jmapc/models/thread.py | 23 +++++ 6 files changed, 210 insertions(+), 184 deletions(-) create mode 100644 jmapc/models/email.py create mode 100644 jmapc/models/identity.py create mode 100644 jmapc/models/mailbox.py create mode 100644 jmapc/models/thread.py diff --git a/jmapc/models/__init__.py b/jmapc/models/__init__.py index c065b6b..5fab322 100644 --- a/jmapc/models/__init__.py +++ b/jmapc/models/__init__.py @@ -1,24 +1,21 @@ -from .models import ( - Comparator, +from .email import ( Email, - EmailAddress, EmailBodyPart, EmailBodyValue, EmailHeader, EmailQueryFilter, EmailQueryFilterCondition, EmailQueryFilterOperator, - Identity, - ListOrRef, +) +from .identity import Identity +from .mailbox import ( Mailbox, MailboxQueryFilter, MailboxQueryFilterCondition, MailboxQueryFilterOperator, - Operator, - StrOrRef, - Thread, - ThreadEmail, ) +from .models import Comparator, EmailAddress, ListOrRef, Operator, StrOrRef +from .thread import Thread, ThreadEmail __all__ = [ "Comparator", diff --git a/jmapc/models/email.py b/jmapc/models/email.py new file mode 100644 index 0000000..3d51647 --- /dev/null +++ b/jmapc/models/email.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, List, Optional, Union + +from dataclasses_json import config + +from ..serializer import Model, datetime_decode, datetime_encode +from .models import EmailAddress, ListOrRef, Operator, StrOrRef + + +@dataclass +class Email(Model): + id: str = field(metadata=config(field_name="Id")) + blob_id: Optional[str] = None + thread_id: Optional[str] = None + mailbox_ids: Optional[Dict[str, bool]] = None + keywords: Optional[Dict[str, bool]] = None + size: Optional[int] = None + received_at: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + message_id: Optional[List[str]] = None + in_reply_to: Optional[List[str]] = None + references: Optional[List[str]] = None + headers: Optional[List[EmailHeader]] = None + mail_from: Optional[List[EmailAddress]] = field( + metadata=config(field_name="from"), default=None + ) + to: Optional[List[EmailAddress]] = None + cc: Optional[List[EmailAddress]] = None + bcc: Optional[List[EmailAddress]] = None + reply_to: Optional[List[EmailAddress]] = None + subject: Optional[str] = None + sent_at: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + body_structure: Optional[EmailBodyPart] = None + body_values: Optional[Dict[str, EmailBodyValue]] = None + text_body: Optional[List[EmailBodyPart]] = None + html_body: Optional[List[EmailBodyPart]] = None + attachments: Optional[List[EmailBodyPart]] = None + has_attachment: Optional[bool] = None + preview: Optional[str] = None + + +@dataclass +class EmailHeader(Model): + name: Optional[str] = None + value: Optional[str] = None + + +@dataclass +class EmailBodyPart(Model): + part_id: Optional[str] = None + blob_id: Optional[str] = None + size: Optional[int] = None + headers: Optional[List[EmailHeader]] = None + name: Optional[str] = None + type: Optional[str] = None + charset: Optional[str] = None + disposition: Optional[str] = None + cid: Optional[str] = None + language: Optional[List[str]] = None + location: Optional[str] = None + sub_parts: Optional[List[EmailBodyPart]] = None + + +@dataclass +class EmailBodyValue(Model): + value: Optional[str] = None + is_encoding_problem: Optional[bool] = None + is_truncated: Optional[bool] = None + + +@dataclass +class EmailQueryFilterCondition(Model): + in_mailbox: Optional[StrOrRef] = None + in_mailbox_other_than: Optional[ListOrRef] = None + before: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + after: Optional[datetime] = field( + default=None, + metadata=config(encoder=datetime_encode, decoder=datetime_decode), + ) + min_size: Optional[int] = None + max_size: Optional[int] = None + all_in_thread_have_keyword: Optional[StrOrRef] = None + some_in_thread_have_keyword: Optional[StrOrRef] = None + none_in_thread_have_keyword: Optional[StrOrRef] = None + has_keyword: Optional[StrOrRef] = None + not_keyword: Optional[StrOrRef] = None + has_attachment: Optional[bool] = None + text: Optional[StrOrRef] = None + mail_from: Optional[str] = field( + metadata=config(field_name="from"), default=None + ) + to: Optional[StrOrRef] = None + cc: Optional[StrOrRef] = None + bcc: Optional[StrOrRef] = None + body: Optional[StrOrRef] = None + header: Optional[ListOrRef] = None + + +@dataclass +class EmailQueryFilterOperator(Model): + operator: Operator + conditions: List[EmailQueryFilter] + + +EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] diff --git a/jmapc/models/identity.py b/jmapc/models/identity.py new file mode 100644 index 0000000..52d9caf --- /dev/null +++ b/jmapc/models/identity.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +from ..serializer import Model +from .models import EmailAddress + + +@dataclass +class Identity(Model): + id: str + name: str + email: str + replyTo: Optional[str] + bcc: Optional[List[EmailAddress]] + textSignature: Optional[str] + htmlSignature: Optional[str] + mayDelete: bool diff --git a/jmapc/models/mailbox.py b/jmapc/models/mailbox.py new file mode 100644 index 0000000..95f7d7a --- /dev/null +++ b/jmapc/models/mailbox.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional, Union + +from dataclasses_json import config + +from ..serializer import Model +from .models import Operator, StrOrRef + + +@dataclass +class Mailbox(Model): + id: str = field(metadata=config(field_name="Id")) + name: str + sort_order: int + total_emails: int + unread_emails: int + total_threads: int + unread_threads: int + is_subscribed: bool + role: Optional[str] = None + parent_id: Optional[str] = field( + metadata=config(field_name="parentId"), default=None + ) + + +@dataclass +class MailboxQueryFilterCondition(Model): + name: Optional[StrOrRef] = None + role: Optional[StrOrRef] = None + parent_id: Optional[StrOrRef] = None + + +@dataclass +class MailboxQueryFilterOperator(Model): + operator: Operator + conditions: List[MailboxQueryFilter] + + +MailboxQueryFilter = Union[ + MailboxQueryFilterCondition, MailboxQueryFilterOperator +] diff --git a/jmapc/models/models.py b/jmapc/models/models.py index 6399897..7bff6f8 100644 --- a/jmapc/models/models.py +++ b/jmapc/models/models.py @@ -1,136 +1,22 @@ from __future__ import annotations -from dataclasses import dataclass, field -from datetime import datetime -from typing import Dict, List, Optional, TypeVar, Union - -from dataclasses_json import config +from dataclasses import dataclass +from typing import List, Optional, TypeVar, Union from ..ref import ResultReference -from ..serializer import Model, datetime_decode, datetime_encode +from ..serializer import Model T = TypeVar("T") StrOrRef = Union[str, ResultReference] ListOrRef = Union[ResultReference, List[T]] -@dataclass -class Identity(Model): - id: str - name: str - email: str - replyTo: Optional[str] - bcc: Optional[List[EmailAddress]] - textSignature: Optional[str] - htmlSignature: Optional[str] - mayDelete: bool - - -@dataclass -class Mailbox(Model): - id: str = field(metadata=config(field_name="Id")) - name: str - sort_order: int - total_emails: int - unread_emails: int - total_threads: int - unread_threads: int - is_subscribed: bool - role: Optional[str] = None - parent_id: Optional[str] = field( - metadata=config(field_name="parentId"), default=None - ) - - -@dataclass -class Email(Model): - id: str = field(metadata=config(field_name="Id")) - blob_id: Optional[str] = None - thread_id: Optional[str] = None - mailbox_ids: Optional[Dict[str, bool]] = None - keywords: Optional[Dict[str, bool]] = None - size: Optional[int] = None - received_at: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - message_id: Optional[List[str]] = None - in_reply_to: Optional[List[str]] = None - references: Optional[List[str]] = None - headers: Optional[List[EmailHeader]] = None - mail_from: Optional[List[EmailAddress]] = field( - metadata=config(field_name="from"), default=None - ) - to: Optional[List[EmailAddress]] = None - cc: Optional[List[EmailAddress]] = None - bcc: Optional[List[EmailAddress]] = None - reply_to: Optional[List[EmailAddress]] = None - subject: Optional[str] = None - sent_at: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - body_structure: Optional[EmailBodyPart] = None - body_values: Optional[Dict[str, EmailBodyValue]] = None - text_body: Optional[List[EmailBodyPart]] = None - html_body: Optional[List[EmailBodyPart]] = None - attachments: Optional[List[EmailBodyPart]] = None - has_attachment: Optional[bool] = None - preview: Optional[str] = None - - @dataclass class EmailAddress(Model): name: Optional[str] = None email: Optional[str] = None -@dataclass -class EmailHeader(Model): - name: Optional[str] = None - value: Optional[str] = None - - -@dataclass -class EmailBodyPart(Model): - part_id: Optional[str] = None - blob_id: Optional[str] = None - size: Optional[int] = None - headers: Optional[List[EmailHeader]] = None - name: Optional[str] = None - type: Optional[str] = None - charset: Optional[str] = None - disposition: Optional[str] = None - cid: Optional[str] = None - language: Optional[List[str]] = None - location: Optional[str] = None - sub_parts: Optional[List[EmailBodyPart]] = None - - -@dataclass -class EmailBodyValue(Model): - value: Optional[str] = None - is_encoding_problem: Optional[bool] = None - is_truncated: Optional[bool] = None - - -@dataclass -class Thread(Model): - def __len__(self) -> int: - return len(self.email_ids) - - id: str - email_ids: List[str] - - -@dataclass -class ThreadEmail(Model): - id: str - mailbox_ids: List[str] - is_unread: bool - is_flagged: bool - - @dataclass class Comparator(Model): property: str @@ -152,61 +38,3 @@ class Operator: AND = "AND" OR = "OR" NOT = "NOT" - - -@dataclass -class EmailQueryFilterCondition(Model): - in_mailbox: Optional[StrOrRef] = None - in_mailbox_other_than: Optional[ListOrRef] = None - before: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - after: Optional[datetime] = field( - default=None, - metadata=config(encoder=datetime_encode, decoder=datetime_decode), - ) - min_size: Optional[int] = None - max_size: Optional[int] = None - all_in_thread_have_keyword: Optional[StrOrRef] = None - some_in_thread_have_keyword: Optional[StrOrRef] = None - none_in_thread_have_keyword: Optional[StrOrRef] = None - has_keyword: Optional[StrOrRef] = None - not_keyword: Optional[StrOrRef] = None - has_attachment: Optional[bool] = None - text: Optional[StrOrRef] = None - mail_from: Optional[str] = field( - metadata=config(field_name="from"), default=None - ) - to: Optional[StrOrRef] = None - cc: Optional[StrOrRef] = None - bcc: Optional[StrOrRef] = None - body: Optional[StrOrRef] = None - header: Optional[ListOrRef] = None - - -@dataclass -class EmailQueryFilterOperator(Model): - operator: Operator - conditions: List[EmailQueryFilter] - - -EmailQueryFilter = Union[EmailQueryFilterCondition, EmailQueryFilterOperator] - - -@dataclass -class MailboxQueryFilterCondition(Model): - name: Optional[StrOrRef] = None - role: Optional[StrOrRef] = None - parent_id: Optional[StrOrRef] = None - - -@dataclass -class MailboxQueryFilterOperator(Model): - operator: Operator - conditions: List[MailboxQueryFilter] - - -MailboxQueryFilter = Union[ - MailboxQueryFilterCondition, MailboxQueryFilterOperator -] diff --git a/jmapc/models/thread.py b/jmapc/models/thread.py new file mode 100644 index 0000000..3ea2465 --- /dev/null +++ b/jmapc/models/thread.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List + +from ..serializer import Model + + +@dataclass +class Thread(Model): + def __len__(self) -> int: + return len(self.email_ids) + + id: str + email_ids: List[str] + + +@dataclass +class ThreadEmail(Model): + id: str + mailbox_ids: List[str] + is_unread: bool + is_flagged: bool From 86dd5ff13f0a591de61c2b6a49e1b563d27ce828 Mon Sep 17 00:00:00 2001 From: Stephen Kent Date: Sat, 26 Feb 2022 10:03:37 -0800 Subject: [PATCH 16/16] Add module import unit test --- tests/test_module.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 tests/test_module.py diff --git a/tests/test_module.py b/tests/test_module.py new file mode 100644 index 0000000..8b332cb --- /dev/null +++ b/tests/test_module.py @@ -0,0 +1,7 @@ +def test_import() -> None: + import jmapc + + assert jmapc.Client + assert jmapc.methods + assert jmapc.models + assert jmapc.errors