diff --git a/examples/search.py b/examples/search.py new file mode 100644 index 0000000..935c3d0 --- /dev/null +++ b/examples/search.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +import argparse +import os + +from jmapc import Client, EmailQueryFilterCondition, MailboxQueryFilterCondition, Ref +from jmapc.methods import EmailQuery, MailboxGet, MailboxGetResponse, MailboxQuery + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--host", "-H", default="email.example.com") + parser.add_argument("--username", "-u", default="me@example.com") + parser.add_argument("--password", "-p", default="keep-it-secret") + parser.add_argument("search", help="The terms to search for.") + args = parser.parse_args() + + + # Create and configure client + #client = Client.create_with_api_token( + #host=os.environ["JMAP_HOST"], api_token=os.environ["JMAP_API_TOKEN"] + #) + client = Client.create_with_password( + host=args.host, + user=args.username, + password=args.password, + ) + + methods = [ + EmailQuery(filter=EmailQueryFilterCondition(text=args.search)), + ] + + # Call JMAP API with the prepared request + results = client.request(methods) + + if not results: + print("No results") + return + + print(f"Found {len(results)} results") + # Retrieve the InvocationResponse for the second method. The InvocationResponse + # contains the client-provided method ID, and the result data model. + method_1_result = results[0] + + # Retrieve the result data model from the InvocationResponse instance + method_1_result_data = method_1_result.response + + # Retrieve the Email data from the result data model + print(method_1_result_data) + +if __name__ == "__main__": + main() diff --git a/jmapc/methods/base.py b/jmapc/methods/base.py index ae6049c..6268850 100644 --- a/jmapc/methods/base.py +++ b/jmapc/methods/base.py @@ -1,14 +1,14 @@ from __future__ import annotations import contextlib -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Any, Dict, List, Optional from typing import Set as SetType from typing import Type, Union, cast from ..errors import Error from ..models import AddedItem, Comparator, ListOrRef, SetError, StrOrRef -from ..serializer import Model +from ..serializer import Model, null_omitted_field class MethodBase(Model): @@ -35,7 +35,7 @@ class Method(MethodBase): @dataclass class MethodWithAccount(Method): - account_id: Optional[str] = field(init=False, default=None) + account_id: Optional[str] = null_omitted_field(init=False) class ResponseCollector(MethodBase): @@ -87,7 +87,7 @@ class ChangesMethod: @dataclass class Changes(MethodWithAccount, ChangesMethod): since_state: str - max_changes: Optional[int] = None + max_changes: Optional[int] = null_omitted_field() @dataclass @@ -107,10 +107,10 @@ class CopyMethod: @dataclass class Copy(MethodWithAccount, CopyMethod): from_account_id: str - if_from_in_state: Optional[str] = None - if_in_state: Optional[str] = None + if_from_in_state: Optional[str] = null_omitted_field() + if_in_state: Optional[str] = null_omitted_field() on_success_destroy_original: bool = False - destroy_from_if_in_state: Optional[str] = None + destroy_from_if_in_state: Optional[str] = null_omitted_field() @dataclass @@ -127,8 +127,8 @@ class GetMethod: @dataclass class Get(MethodWithAccount, GetMethod): - ids: Optional[ListOrRef[str]] - properties: Optional[List[str]] = None + ids: Optional[ListOrRef[str]] = null_omitted_field() + properties: Optional[List[str]] = null_omitted_field() @dataclass @@ -147,10 +147,10 @@ class SetMethod: @dataclass class Set(MethodWithAccount, SetMethod): - if_in_state: Optional[StrOrRef] = None - create: Optional[Dict[str, Any]] = None - update: Optional[Dict[str, Dict[str, Any]]] = None - destroy: Optional[ListOrRef[str]] = None + if_in_state: Optional[StrOrRef] = null_omitted_field() + create: Optional[Dict[str, Any]] = null_omitted_field() + update: Optional[Dict[str, Dict[str, Any]]] = null_omitted_field() + destroy: Optional[ListOrRef[str]] = null_omitted_field() @dataclass @@ -160,9 +160,9 @@ class SetResponse(ResponseWithAccount, SetMethod): created: Optional[Dict[str, Any]] updated: Optional[Dict[str, Any]] destroyed: Optional[List[str]] - not_created: Optional[Dict[str, SetError]] = None - not_updated: Optional[Dict[str, SetError]] = None - not_destroyed: Optional[Dict[str, SetError]] = None + not_created: Optional[Dict[str, SetError]] = null_omitted_field() + not_updated: Optional[Dict[str, SetError]] = null_omitted_field() + not_destroyed: Optional[Dict[str, SetError]] = null_omitted_field() class QueryMethod: @@ -171,12 +171,12 @@ class QueryMethod: @dataclass class Query(MethodWithAccount, QueryMethod): - sort: Optional[List[Comparator]] = None - position: Optional[int] = None - anchor: Optional[str] = None - anchor_offset: Optional[int] = None - limit: Optional[int] = None - calculate_total: Optional[bool] = None + sort: Optional[List[Comparator]] = null_omitted_field() + position: Optional[int] = null_omitted_field() + anchor: Optional[str] = null_omitted_field() + anchor_offset: Optional[int] = null_omitted_field() + limit: Optional[int] = null_omitted_field() + calculate_total: Optional[bool] = null_omitted_field() @dataclass @@ -185,8 +185,8 @@ class QueryResponse(ResponseWithAccount, QueryMethod): can_calculate_changes: bool position: int ids: ListOrRef[str] - total: Optional[int] = None - limit: Optional[int] = None + total: Optional[int] = null_omitted_field() + limit: Optional[int] = null_omitted_field() class QueryChangesMethod: @@ -195,10 +195,10 @@ class QueryChangesMethod: @dataclass class QueryChanges(MethodWithAccount, QueryChangesMethod): - sort: Optional[List[Comparator]] = None - since_query_state: Optional[str] = None - max_changes: Optional[int] = None - up_to_id: Optional[str] = None + sort: Optional[List[Comparator]] = null_omitted_field() + since_query_state: Optional[str] = null_omitted_field() + max_changes: Optional[int] = null_omitted_field() + up_to_id: Optional[str] = null_omitted_field() calculate_total: bool = False @@ -208,7 +208,7 @@ class QueryChangesResponse(ResponseWithAccount, QueryChangesMethod): new_query_state: str removed: List[str] added: List[AddedItem] - total: Optional[int] = None + total: Optional[int] = null_omitted_field() ResponseOrError = Union[Error, Response] diff --git a/jmapc/methods/core.py b/jmapc/methods/core.py index 6e55783..5b472d6 100644 --- a/jmapc/methods/core.py +++ b/jmapc/methods/core.py @@ -5,6 +5,7 @@ from .. import constants from .base import Method, Response +from ..serializer import null_omitted_field class CoreBase: @@ -21,12 +22,12 @@ class CoreEcho(CoreBase, EchoMethod, Method): def to_dict(self, *args: Any, **kwargs: Any) -> Dict[str, Any]: return self.data or dict() - data: Optional[Dict[str, Any]] = None + data: Optional[Dict[str, Any]] = null_omitted_field() @dataclass class CoreEchoResponse(CoreBase, EchoMethod, Response): - data: Optional[Dict[str, Any]] = None + data: Optional[Dict[str, Any]] = null_omitted_field() @classmethod def from_dict( diff --git a/jmapc/methods/mailbox.py b/jmapc/methods/mailbox.py index 356b4ce..787f5e1 100644 --- a/jmapc/methods/mailbox.py +++ b/jmapc/methods/mailbox.py @@ -7,6 +7,7 @@ from .. import constants from ..models import Mailbox, MailboxQueryFilter +from ..serializer import null_omitted_field from .base import ( Changes, ChangesResponse, @@ -48,7 +49,7 @@ class MailboxGetResponse(MailboxBase, GetResponse): @dataclass class MailboxQuery(MailboxBase, Query): - filter: Optional[MailboxQueryFilter] = None + filter: Optional[MailboxQueryFilter] = null_omitted_field() sort_as_tree: bool = False filter_as_tree: bool = False diff --git a/jmapc/models/email.py b/jmapc/models/email.py index bd868ba..f8841d5 100644 --- a/jmapc/models/email.py +++ b/jmapc/models/email.py @@ -6,45 +6,50 @@ from dataclasses_json import config -from ..serializer import Model, datetime_decode, datetime_encode +from ..serializer import ( + Model, + datetime_decode, + datetime_encode, + null_omitted_field, +) from .models import EmailAddress, ListOrRef, Operator, StrOrRef @dataclass class Email(Model): id: Optional[str] = field(metadata=config(field_name="id"), default=None) - 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( + blob_id: Optional[str] = null_omitted_field() + thread_id: Optional[str] = null_omitted_field() + mailbox_ids: Optional[Dict[str, bool]] = null_omitted_field() + keywords: Optional[Dict[str, bool]] = null_omitted_field() + size: Optional[int] = null_omitted_field() + received_at: Optional[datetime] = null_omitted_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 + message_id: Optional[List[str]] = null_omitted_field() + in_reply_to: Optional[List[str]] = null_omitted_field() + references: Optional[List[str]] = null_omitted_field() + headers: Optional[List[EmailHeader]] = null_omitted_field() + mail_from: Optional[List[EmailAddress]] = null_omitted_field( + default=None, metadata=config(field_name="from") ) - 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( + to: Optional[List[EmailAddress]] = null_omitted_field() + cc: Optional[List[EmailAddress]] = null_omitted_field() + bcc: Optional[List[EmailAddress]] = null_omitted_field() + reply_to: Optional[List[EmailAddress]] = null_omitted_field() + subject: Optional[str] = null_omitted_field() + sent_at: Optional[datetime] = null_omitted_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 + body_structure: Optional[EmailBodyPart] = null_omitted_field() + body_values: Optional[Dict[str, EmailBodyValue]] = null_omitted_field() + text_body: Optional[List[EmailBodyPart]] = null_omitted_field() + html_body: Optional[List[EmailBodyPart]] = null_omitted_field() + attachments: Optional[List[EmailBodyPart]] = null_omitted_field() + has_attachment: Optional[bool] = null_omitted_field() + preview: Optional[str] = null_omitted_field() @dataclass @@ -97,8 +102,9 @@ class EmailQueryFilterCondition(Model): 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 + mail_from: Optional[str] = null_omitted_field( + default=None, + metadata=config(field_name="from"), ) to: Optional[StrOrRef] = None cc: Optional[StrOrRef] = None diff --git a/jmapc/models/mailbox.py b/jmapc/models/mailbox.py index 9082830..8b49f15 100644 --- a/jmapc/models/mailbox.py +++ b/jmapc/models/mailbox.py @@ -5,33 +5,33 @@ from dataclasses_json import config -from ..serializer import Model +from ..serializer import Model, null_omitted_field from .models import Operator, StrOrRef @dataclass class Mailbox(Model): - id: Optional[str] = field(metadata=config(field_name="id"), default=None) - name: Optional[str] = None + id: Optional[str] = null_omitted_field(metadata=config(field_name="id")) + name: Optional[str] = null_omitted_field() sort_order: Optional[int] = 0 - total_emails: Optional[int] = None - unread_emails: Optional[int] = None - total_threads: Optional[int] = None - unread_threads: Optional[int] = None + total_emails: Optional[int] = null_omitted_field() + unread_emails: Optional[int] = null_omitted_field() + total_threads: Optional[int] = null_omitted_field() + unread_threads: Optional[int] = null_omitted_field() is_subscribed: Optional[bool] = False - role: Optional[str] = None - parent_id: Optional[str] = field( - metadata=config(field_name="parentId"), default=None + role: Optional[str] = null_omitted_field() + parent_id: Optional[str] = null_omitted_field( + metadata=config(field_name="parentId"), ) @dataclass class MailboxQueryFilterCondition(Model): - name: Optional[StrOrRef] = None - role: Optional[StrOrRef] = None - parent_id: Optional[StrOrRef] = None - has_any_role: Optional[bool] = None - is_subscribed: Optional[bool] = None + name: Optional[StrOrRef] = null_omitted_field() + role: Optional[StrOrRef] = null_omitted_field() + parent_id: Optional[StrOrRef] = null_omitted_field() + has_any_role: Optional[bool] = null_omitted_field() + is_subscribed: Optional[bool] = null_omitted_field() @dataclass diff --git a/jmapc/serializer.py b/jmapc/serializer.py index f694e4b..4baec67 100644 --- a/jmapc/serializer.py +++ b/jmapc/serializer.py @@ -1,8 +1,9 @@ from __future__ import annotations import contextlib +from dataclasses import field from datetime import datetime -from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Mapping, cast import dataclasses_json import dateutil.parser @@ -114,7 +115,6 @@ class Model(dataclasses_json.DataClassJsonMixin): dataclass_json_config = dataclasses_json.config( letter_case=dataclasses_json.LetterCase.CAMEL, # type: ignore undefined=dataclasses_json.Undefined.EXCLUDE, - exclude=lambda f: f is None, )["dataclasses_json"] def to_dict( @@ -128,3 +128,20 @@ def to_dict( self.account_id: Optional[str] = account_id todict = ModelToDictPostprocessor(method_calls_slice) return todict.postprocess(super().to_dict(*args, **kwargs)) + + +def exclude(v: Optional[Any]) -> bool: + return v is None + + +def null_omitted_field( + *args: Any, + metadata: Optional[Mapping[str, Any]] = None, + default: Optional[Any] = None, + **kwargs: Any, +) -> Optional[Any]: + if metadata is None: + metadata = dataclasses_json.config(exclude=exclude) + else: + metadata["dataclasses_json"]["exclude"] = exclude + return field(*args, metadata=metadata, default=default, **kwargs) diff --git a/tests/methods/test_mailbox.py b/tests/methods/test_mailbox.py index 3f89581..3e172c6 100644 --- a/tests/methods/test_mailbox.py +++ b/tests/methods/test_mailbox.py @@ -151,6 +151,87 @@ def test_mailbox_get( ) +def test_mailbox_get_all( + client: Client, http_responses: responses.RequestsMock +) -> None: + expected_request = { + "methodCalls": [ + [ + "Mailbox/get", + {"accountId": "u1138", "ids": None}, + "single.Mailbox/get", + ] + ], + "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", + }, + "single.Mailbox/get", + ] + ] + } + expect_jmap_call(http_responses, expected_request, response) + assert client.request(MailboxGet(ids=None)) == 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, + ), + ], + ) + + def test_mailbox_query( client: Client, http_responses: responses.RequestsMock ) -> None: diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 1b64acb..07c82ac 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -7,7 +7,12 @@ from jmapc import EmailHeader, ResultReference from jmapc.models import ListOrRef -from jmapc.serializer import Model, datetime_decode, datetime_encode +from jmapc.serializer import ( + Model, + datetime_decode, + datetime_encode, + null_omitted_field, +) def test_camel_case() -> None: @@ -100,7 +105,7 @@ def test_serialize_datetime( ) -> None: @dataclass class TestModel(Model): - timestamp: Optional[datetime] = field( + timestamp: Optional[datetime] = null_omitted_field( default=None, metadata=config(encoder=datetime_encode, decoder=datetime_decode), )