From 9efc7b345440debb079debdc4f43ce6292e1025d Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Fri, 5 Jan 2024 16:01:10 -0700 Subject: [PATCH 1/2] Denote every field which is omitted when None. There are some fields that can be sent with `null` values over JSON, which means they are set to `None` within Python. The broad config-based exclude directive is not given any information about the field, only it's value, so we can't use it to determine if a field should be excluded or not in the dict that gets built. Instead we create a helper function to build a field definition that has the necessary exclude function. Any field that does not use this helper function is permitted to be None/null. This is true of the MailboxGet.ids field in this commit. A new test is added to maintain the behavior over time. --- jmapc/methods/base.py | 58 ++++++++++++------------- jmapc/methods/core.py | 5 ++- jmapc/methods/mailbox.py | 3 +- jmapc/models/email.py | 62 +++++++++++++++------------ jmapc/models/mailbox.py | 30 ++++++------- jmapc/serializer.py | 21 ++++++++- tests/methods/test_mailbox.py | 81 +++++++++++++++++++++++++++++++++++ tests/test_serializer.py | 9 +++- 8 files changed, 190 insertions(+), 79 deletions(-) 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), ) From 4fdc2bc779c6aa49fadc38881a435dc07e946d8d Mon Sep 17 00:00:00 2001 From: Eli Ribble Date: Tue, 30 Jul 2024 19:32:59 -0700 Subject: [PATCH 2/2] Add search example --- examples/search.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 examples/search.py 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()