Repository: duesee/imap-codec (language: rust)

Issue:
binding(Python): Use Python classes for codec messages
Currently, the codec messages are typeless dictionaries, e.g. `{"tag": "a", "body": "Noop"}`.

Using classes (e.g. `Command`, `Greeting`) for each of these messages would allow making it more clear what type the methods accept and return, even if these classes itself are constructed from dictionaries, e.g. `Command({"tag": "a", "body": "Noop"})`.


Discussion / hints:
I think this is a reasonable next step towards a pythonic API.

Fix patch:
diff --git a/bindings/imap-codec-python/imap_codec.pyi b/bindings/imap-codec-python/imap_codec.pyi
index 33e0544a..bea40091 100644
--- a/bindings/imap-codec-python/imap_codec.pyi
+++ b/bindings/imap-codec-python/imap_codec.pyi
@@ -108,13 +108,37 @@ class Encoded:
         Dump the (remaining) encoded data without being guided by fragments.
         """
 
+class Greeting:
+    """
+    Greeting.
+
+    Note: Don't use `code: None` *and* a `text` that starts with "[" as this would be ambiguous in IMAP.
+    We could fix this but the fix would make this type unconformable to use.
+    """
+
+    @staticmethod
+    def from_dict(greeting: dict) -> Greeting:
+        """
+        Create greeting from `dict`
+
+        :param greeting: Dictionary representation of greeting
+        :raises RuntimeError: Dictionary could not be deserialized into greeting
+        """
+
+    def as_dict(self) -> dict:
+        """
+        Return greeting as `dict`
+
+        :return: Dictionary representation of greeting
+        """
+
 class GreetingCodec:
     """
     Codec for greetings.
     """
 
     @staticmethod
-    def decode(bytes: bytes) -> Tuple[bytes, dict]:
+    def decode(bytes: bytes) -> Tuple[bytes, Greeting]:
         """
         Decode greeting from given bytes.
 
@@ -125,21 +149,42 @@ class GreetingCodec:
         """
 
     @staticmethod
-    def encode(greeting: dict) -> Encoded:
+    def encode(greeting: Greeting) -> Encoded:
         """
         Encode greeting into fragments.
 
-        :param bytes: Given greeting
+        :param greeting: Given greeting
         :return: `Encoded` type holding fragments of encoded greeting
         """
 
+class Command:
+    """
+    Command.
+    """
+
+    @staticmethod
+    def from_dict(command: dict) -> Command:
+        """
+        Create command from `dict`
+
+        :param command: Dictionary representation of command
+        :raises RuntimeError: Dictionary could not be deserialized into command
+        """
+
+    def as_dict(self) -> dict:
+        """
+        Return command as `dict`
+
+        :return: Dictionary representation of command
+        """
+
 class CommandCodec:
     """
     Codec for commands.
     """
 
     @staticmethod
-    def decode(bytes: bytes) -> Tuple[bytes, dict]:
+    def decode(bytes: bytes) -> Tuple[bytes, Command]:
         """
         Decode command from given bytes.
 
@@ -151,21 +196,44 @@ class CommandCodec:
         """
 
     @staticmethod
-    def encode(command: dict) -> Encoded:
+    def encode(command: Command) -> Encoded:
         """
         Encode command into fragments.
 
-        :param bytes: Given command
+        :param command: Given command
         :return: `Encoded` type holding fragments of encoded command
         """
 
+class AuthenticateData:
+    """
+    Authenticate data line
+
+    Data line used, e.g., during AUTHENTICATE.
+    """
+
+    @staticmethod
+    def from_dict(authenticate_data: dict) -> AuthenticateData:
+        """
+        Create authenticate data line from `dict`
+
+        :param authenticate_data: Dictionary representation of authenticate data line
+        :raises RuntimeError: Dictionary could not be deserialized into authenticate data line
+        """
+
+    def as_dict(self) -> dict:
+        """
+        Return authenticate data line as `dict`
+
+        :return: Dictionary representation of authenticate data line
+        """
+
 class AuthenticateDataCodec:
     """
     Codec for authenticate data lines.
     """
 
     @staticmethod
-    def decode(bytes: bytes) -> Tuple[bytes, dict]:
+    def decode(bytes: bytes) -> Tuple[bytes, AuthenticateData]:
         """
         Decode authenticate data line from given bytes.
 
@@ -176,21 +244,42 @@ class AuthenticateDataCodec:
         """
 
     @staticmethod
-    def encode(authenticate_data: dict) -> Encoded:
+    def encode(authenticate_data: AuthenticateData) -> Encoded:
         """
         Encode authenticate data line into fragments.
 
-        :param bytes: Given authenticate data line
+        :param authenticate_data: Given authenticate data line
         :return: `Encoded` type holding fragments of encoded authenticate data line
         """
 
+class Response:
+    """
+    Response.
+    """
+
+    @staticmethod
+    def from_dict(response: dict) -> Response:
+        """
+        Create response from `dict`
+
+        :param response: Dictionary representation of response
+        :raises RuntimeError: Dictionary could not be deserialized into response
+        """
+
+    def as_dict(self) -> dict:
+        """
+        Return response as `dict`
+
+        :return: Dictionary representation of response
+        """
+
 class ResponseCodec:
     """
     Codec for responses.
     """
 
     @staticmethod
-    def decode(bytes: bytes) -> Tuple[bytes, dict]:
+    def decode(bytes: bytes) -> Tuple[bytes, Response]:
         """
         Decode response from given bytes.
 
@@ -202,21 +291,31 @@ class ResponseCodec:
         """
 
     @staticmethod
-    def encode(response: dict) -> Encoded:
+    def encode(response: Response) -> Encoded:
         """
         Encode response into fragments.
 
-        :param bytes: Given response
+        :param response: Given response
         :return: `Encoded` type holding fragments of encoded response
         """
 
+class IdleDone:
+    """
+    Denotes the continuation data message "DONE\r\n" to end the IDLE command.
+    """
+
+    def __new__(cls) -> IdleDone:
+        """
+        Create idle done
+        """
+
 class IdleDoneCodec:
     """
     Codec for idle dones.
     """
 
     @staticmethod
-    def decode(bytes: bytes) -> Tuple[bytes, Tuple[()]]:
+    def decode(bytes: bytes) -> Tuple[bytes, IdleDone]:
         """
         Decode idle done from given bytes.
 
@@ -228,10 +327,10 @@ class IdleDoneCodec:
         """
 
     @staticmethod
-    def encode(idle_done: Tuple[()]) -> Encoded:
+    def encode(idle_done: IdleDone) -> Encoded:
         """
         Encode idle done into fragments.
 
-        :param bytes: Given idle done
+        :param idle_done: Given idle done
         :return: `Encoded` type holding fragments of encoded idle done
         """
diff --git a/bindings/imap-codec-python/src/lib.rs b/bindings/imap-codec-python/src/lib.rs
index da568fb4..1cb7d1ff 100644
--- a/bindings/imap-codec-python/src/lib.rs
+++ b/bindings/imap-codec-python/src/lib.rs
@@ -1,11 +1,14 @@
 mod encoded;
+mod messages;
 
 use encoded::PyEncoded;
 use imap_codec::{
     decode::{self, Decoder},
     encode::Encoder,
+    imap_types::IntoStatic,
     AuthenticateDataCodec, CommandCodec, GreetingCodec, IdleDoneCodec, ResponseCodec,
 };
+use messages::{PyAuthenticateData, PyCommand, PyGreeting, PyIdleDone, PyResponse};
 use pyo3::{create_exception, exceptions::PyException, prelude::*, types::PyBytes};
 
 // Create exception types for decode errors
@@ -23,7 +26,7 @@ struct PyGreetingCodec;
 impl PyGreetingCodec {
     /// Decode greeting from given bytes
     #[staticmethod]
-    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, Bound<PyAny>)> {
+    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, PyGreeting)> {
         let py = bytes.py();
         let (remaining, greeting) =
             GreetingCodec::default()
@@ -34,16 +37,15 @@ impl PyGreetingCodec {
                 })?;
         Ok((
             PyBytes::new_bound(py, remaining),
-            serde_pyobject::to_pyobject(py, &greeting)?,
+            PyGreeting(greeting.into_static()),
         ))
     }
 
     /// Encode greeting into fragments
     #[staticmethod]
-    fn encode(greeting: Bound<PyAny>) -> PyResult<PyEncoded> {
-        let greeting = serde_pyobject::from_pyobject(greeting)?;
-        let encoded = GreetingCodec::default().encode(&greeting);
-        Ok(PyEncoded(Some(encoded)))
+    fn encode(greeting: &PyGreeting) -> PyEncoded {
+        let encoded = GreetingCodec::default().encode(&greeting.0);
+        PyEncoded(Some(encoded))
     }
 }
 
@@ -56,12 +58,12 @@ struct PyCommandCodec;
 impl PyCommandCodec {
     /// Decode command from given bytes
     #[staticmethod]
-    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, Bound<PyAny>)> {
+    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, PyCommand)> {
         let py = bytes.py();
         match CommandCodec::default().decode(bytes.as_bytes()) {
             Ok((remaining, command)) => Ok((
                 PyBytes::new_bound(py, remaining),
-                serde_pyobject::to_pyobject(py, &command)?,
+                PyCommand(command.into_static()),
             )),
             Err(err) => Err(match err {
                 decode::CommandDecodeError::Incomplete => DecodeIncomplete::new_err(()),
@@ -79,10 +81,9 @@ impl PyCommandCodec {
 
     /// Encode command into fragments
     #[staticmethod]
-    fn encode(command: Bound<PyAny>) -> PyResult<PyEncoded> {
-        let command = serde_pyobject::from_pyobject(command)?;
-        let encoded = CommandCodec::default().encode(&command);
-        Ok(PyEncoded(Some(encoded)))
+    fn encode(command: &PyCommand) -> PyEncoded {
+        let encoded = CommandCodec::default().encode(&command.0);
+        PyEncoded(Some(encoded))
     }
 }
 
@@ -95,12 +96,12 @@ struct PyAuthenticateDataCodec;
 impl PyAuthenticateDataCodec {
     /// Decode authenticate data line from given bytes
     #[staticmethod]
-    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, Bound<PyAny>)> {
+    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, PyAuthenticateData)> {
         let py = bytes.py();
         match AuthenticateDataCodec::default().decode(bytes.as_bytes()) {
             Ok((remaining, authenticate_data)) => Ok((
                 PyBytes::new_bound(py, remaining),
-                serde_pyobject::to_pyobject(py, &authenticate_data)?,
+                PyAuthenticateData(authenticate_data.into_static()),
             )),
             Err(err) => Err(match err {
                 decode::AuthenticateDataDecodeError::Incomplete => DecodeIncomplete::new_err(()),
@@ -111,10 +112,9 @@ impl PyAuthenticateDataCodec {
 
     /// Encode authenticate data line into fragments
     #[staticmethod]
-    fn encode(authenticate_data: Bound<PyAny>) -> PyResult<PyEncoded> {
-        let authenticate_data = serde_pyobject::from_pyobject(authenticate_data)?;
-        let encoded = AuthenticateDataCodec::default().encode(&authenticate_data);
-        Ok(PyEncoded(Some(encoded)))
+    fn encode(authenticate_data: &PyAuthenticateData) -> PyEncoded {
+        let encoded = AuthenticateDataCodec::default().encode(&authenticate_data.0);
+        PyEncoded(Some(encoded))
     }
 }
 
@@ -127,12 +127,12 @@ struct PyResponseCodec;
 impl PyResponseCodec {
     /// Decode response from given bytes
     #[staticmethod]
-    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, Bound<PyAny>)> {
+    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, PyResponse)> {
         let py = bytes.py();
         match ResponseCodec::default().decode(bytes.as_bytes()) {
             Ok((remaining, response)) => Ok((
                 PyBytes::new_bound(py, remaining),
-                serde_pyobject::to_pyobject(py, &response)?,
+                PyResponse(response.into_static()),
             )),
             Err(err) => Err(match err {
                 decode::ResponseDecodeError::Incomplete => DecodeIncomplete::new_err(()),
@@ -148,10 +148,9 @@ impl PyResponseCodec {
 
     /// Encode response into fragments
     #[staticmethod]
-    fn encode(response: Bound<PyAny>) -> PyResult<PyEncoded> {
-        let response = serde_pyobject::from_pyobject(response)?;
-        let encoded = ResponseCodec::default().encode(&response);
-        Ok(PyEncoded(Some(encoded)))
+    fn encode(response: &PyResponse) -> PyEncoded {
+        let encoded = ResponseCodec::default().encode(&response.0);
+        PyEncoded(Some(encoded))
     }
 }
 
@@ -164,12 +163,12 @@ struct PyIdleDoneCodec;
 impl PyIdleDoneCodec {
     /// Decode idle done from given bytes
     #[staticmethod]
-    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, Bound<PyAny>)> {
+    fn decode(bytes: Bound<PyBytes>) -> PyResult<(Bound<PyBytes>, PyIdleDone)> {
         let py = bytes.py();
         match IdleDoneCodec::default().decode(bytes.as_bytes()) {
             Ok((remaining, idle_done)) => Ok((
                 PyBytes::new_bound(py, remaining),
-                serde_pyobject::to_pyobject(py, &idle_done)?,
+                PyIdleDone(idle_done.into_static()),
             )),
             Err(err) => Err(match err {
                 decode::IdleDoneDecodeError::Incomplete => DecodeIncomplete::new_err(()),
@@ -180,10 +179,9 @@ impl PyIdleDoneCodec {
 
     /// Encode idle done into fragments
     #[staticmethod]
-    fn encode(idle_done: Bound<PyAny>) -> PyResult<PyEncoded> {
-        let idle_done = serde_pyobject::from_pyobject(idle_done)?;
-        let encoded = IdleDoneCodec::default().encode(&idle_done);
-        Ok(PyEncoded(Some(encoded)))
+    fn encode(idle_done: &PyIdleDone) -> PyEncoded {
+        let encoded = IdleDoneCodec::default().encode(&idle_done.0);
+        PyEncoded(Some(encoded))
     }
 }
 
@@ -204,10 +202,15 @@ fn imap_codec_python(m: &Bound<'_, PyModule>) -> PyResult<()> {
     m.add_class::<encoded::PyLineFragment>()?;
     m.add_class::<encoded::PyLiteralFragment>()?;
     m.add_class::<PyEncoded>()?;
+    m.add_class::<PyGreeting>()?;
     m.add_class::<PyGreetingCodec>()?;
+    m.add_class::<PyCommand>()?;
     m.add_class::<PyCommandCodec>()?;
+    m.add_class::<PyAuthenticateData>()?;
     m.add_class::<PyAuthenticateDataCodec>()?;
+    m.add_class::<PyResponse>()?;
     m.add_class::<PyResponseCodec>()?;
+    m.add_class::<PyIdleDone>()?;
     m.add_class::<PyIdleDoneCodec>()?;
 
     Ok(())
diff --git a/bindings/imap-codec-python/src/messages.rs b/bindings/imap-codec-python/src/messages.rs
new file mode 100644
index 00000000..09f526a4
--- /dev/null
+++ b/bindings/imap-codec-python/src/messages.rs
@@ -0,0 +1,128 @@
+use imap_codec::imap_types::{
+    auth::AuthenticateData,
+    command::Command,
+    extensions::idle::IdleDone,
+    response::{Greeting, Response},
+};
+use pyo3::{
+    prelude::*,
+    types::{PyDict, PyString},
+};
+
+/// Python wrapper class around `Greeting`
+#[derive(Debug, Clone, PartialEq)]
+#[pyclass(name = "Greeting", eq)]
+pub(crate) struct PyGreeting(pub(crate) Greeting<'static>);
+
+#[pymethods]
+impl PyGreeting {
+    /// Deserialize greeting from dictionary
+    #[staticmethod]
+    pub(crate) fn from_dict(greeting: Bound<PyDict>) -> PyResult<Self> {
+        Ok(Self(serde_pyobject::from_pyobject(greeting)?))
+    }
+
+    /// Serialize greeting into dictionary
+    pub(crate) fn as_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
+        Ok(serde_pyobject::to_pyobject(py, &self.0)?.downcast_into()?)
+    }
+
+    pub(crate) fn __repr__(&self, py: Python) -> PyResult<String> {
+        Ok(format!("Greeting({})", self.as_dict(py)?))
+    }
+}
+
+/// Python wrapper class around `Command`
+#[derive(Debug, Clone, PartialEq)]
+#[pyclass(name = "Command", eq)]
+pub(crate) struct PyCommand(pub(crate) Command<'static>);
+
+#[pymethods]
+impl PyCommand {
+    /// Deserialize command from dictionary
+    #[staticmethod]
+    pub(crate) fn from_dict(command: Bound<PyDict>) -> PyResult<Self> {
+        Ok(Self(serde_pyobject::from_pyobject(command)?))
+    }
+
+    /// Serialize command into dictionary
+    pub(crate) fn as_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
+        Ok(serde_pyobject::to_pyobject(py, &self.0)?.downcast_into()?)
+    }
+
+    pub(crate) fn __repr__(&self, py: Python) -> PyResult<String> {
+        Ok(format!("Command({:?})", self.as_dict(py)?))
+    }
+}
+
+/// Python wrapper class around `AuthenticateData`
+#[derive(Debug, Clone, PartialEq)]
+#[pyclass(name = "AuthenticateData", eq)]
+pub(crate) struct PyAuthenticateData(pub(crate) AuthenticateData<'static>);
+
+#[pymethods]
+impl PyAuthenticateData {
+    /// Deserialize authenticate data line from dictionary
+    #[staticmethod]
+    pub(crate) fn from_dict(authenticate_data: Bound<PyDict>) -> PyResult<Self> {
+        Ok(Self(serde_pyobject::from_pyobject(authenticate_data)?))
+    }
+
+    /// Serialize authenticate data line into dictionary
+    pub(crate) fn as_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
+        let object = serde_pyobject::to_pyobject(py, &self.0)?;
+        Ok(if object.is_instance_of::<PyString>() {
+            // Unit variants are deserialized into strings by `to_pyobject`, create a
+            // dictionary around it for a consistent interface: `{ "Variant": {} }`
+            let dict = PyDict::new_bound(py);
+            dict.set_item(object, PyDict::new_bound(py))?;
+            dict
+        } else {
+            object.downcast_into()?
+        })
+    }
+
+    pub(crate) fn __repr__(&self, py: Python) -> PyResult<String> {
+        Ok(format!("AuthenticateData({:?})", self.as_dict(py)?))
+    }
+}
+
+/// Python wrapper class around `Response`
+#[derive(Debug, Clone, PartialEq)]
+#[pyclass(name = "Response", eq)]
+pub(crate) struct PyResponse(pub(crate) Response<'static>);
+
+#[pymethods]
+impl PyResponse {
+    /// Deserialize response from dictionary
+    #[staticmethod]
+    pub(crate) fn from_dict(response: Bound<PyDict>) -> PyResult<Self> {
+        Ok(Self(serde_pyobject::from_pyobject(response)?))
+    }
+
+    /// Serialize response into dictionary
+    pub(crate) fn as_dict<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyDict>> {
+        Ok(serde_pyobject::to_pyobject(py, &self.0)?.downcast_into()?)
+    }
+
+    pub(crate) fn __repr__(&self, py: Python) -> PyResult<String> {
+        Ok(format!("Response({:?})", self.as_dict(py)?))
+    }
+}
+
+/// Python wrapper class around `IdleDone`
+#[derive(Debug, Clone, PartialEq)]
+#[pyclass(name = "IdleDone", eq)]
+pub(crate) struct PyIdleDone(pub(crate) IdleDone);
+
+#[pymethods]
+impl PyIdleDone {
+    #[new]
+    pub(crate) fn new() -> Self {
+        Self(IdleDone)
+    }
+
+    pub(crate) fn __repr__(&self) -> &str {
+        "IdleDone"
+    }
+}


Test patch:
diff --git a/bindings/imap-codec-python/tests/test_authenticate_data_decode.py b/bindings/imap-codec-python/tests/test_authenticate_data_decode.py
index a6a05b63..7016318f 100644
--- a/bindings/imap-codec-python/tests/test_authenticate_data_decode.py
+++ b/bindings/imap-codec-python/tests/test_authenticate_data_decode.py
@@ -1,19 +1,28 @@
 import unittest
 
-from imap_codec import AuthenticateDataCodec, DecodeFailed, DecodeIncomplete
+from imap_codec import (
+    AuthenticateData,
+    AuthenticateDataCodec,
+    DecodeFailed,
+    DecodeIncomplete,
+)
 
 
 class TestAuthenticateDataDecode(unittest.TestCase):
     def test_authenticate_data(self):
         buffer = b"VGVzdA==\r\n<remaining>"
         remaining, authenticate_data = AuthenticateDataCodec.decode(buffer)
-        self.assertEqual(authenticate_data, {"Continue": list(b"Test")})
+        self.assertEqual(
+            authenticate_data, AuthenticateData.from_dict({"Continue": list(b"Test")})
+        )
         self.assertEqual(remaining, b"<remaining>")
 
     def test_authenticate_data_without_remaining(self):
         buffer = b"VGVzdA==\r\n"
         remaining, authenticate_data = AuthenticateDataCodec.decode(buffer)
-        self.assertEqual(authenticate_data, {"Continue": list(b"Test")})
+        self.assertEqual(
+            authenticate_data, AuthenticateData.from_dict({"Continue": list(b"Test")})
+        )
         self.assertEqual(remaining, b"")
 
     def test_authenticate_data_error_incomplete(self):
diff --git a/bindings/imap-codec-python/tests/test_authenticate_data_encode.py b/bindings/imap-codec-python/tests/test_authenticate_data_encode.py
index d21ff726..7aeb2d53 100644
--- a/bindings/imap-codec-python/tests/test_authenticate_data_encode.py
+++ b/bindings/imap-codec-python/tests/test_authenticate_data_encode.py
@@ -1,18 +1,18 @@
 import unittest
 
-from imap_codec import AuthenticateDataCodec, Encoded, LineFragment
+from imap_codec import AuthenticateData, AuthenticateDataCodec, Encoded, LineFragment
 
 
 class TestAuthenticateDataEncode(unittest.TestCase):
     def test_authenticate_data(self):
-        authenticate_data = {"Continue": list(b"Test")}
+        authenticate_data = AuthenticateData.from_dict({"Continue": list(b"Test")})
         encoded = AuthenticateDataCodec.encode(authenticate_data)
         self.assertIsInstance(encoded, Encoded)
         fragments = list(encoded)
         self.assertEqual(fragments, [LineFragment(b"VGVzdA==\r\n")])
 
     def test_authenticate_data_dump(self):
-        authenticate_data = {"Continue": list(b"Test")}
+        authenticate_data = AuthenticateData.from_dict({"Continue": list(b"Test")})
         encoded = AuthenticateDataCodec.encode(authenticate_data)
         self.assertIsInstance(encoded, Encoded)
         self.assertEqual(encoded.dump(), b"VGVzdA==\r\n")
diff --git a/bindings/imap-codec-python/tests/test_command_decode.py b/bindings/imap-codec-python/tests/test_command_decode.py
index 3059acab..c39b3110 100644
--- a/bindings/imap-codec-python/tests/test_command_decode.py
+++ b/bindings/imap-codec-python/tests/test_command_decode.py
@@ -1,19 +1,25 @@
 import unittest
 
-from imap_codec import CommandCodec, DecodeFailed, DecodeIncomplete, DecodeLiteralFound
+from imap_codec import (
+    Command,
+    CommandCodec,
+    DecodeFailed,
+    DecodeIncomplete,
+    DecodeLiteralFound,
+)
 
 
 class TestCommandDecode(unittest.TestCase):
     def test_command(self):
         buffer = b"a NOOP\r\n<remaining>"
         remaining, command = CommandCodec.decode(buffer)
-        self.assertEqual(command, {"tag": "a", "body": "Noop"})
+        self.assertEqual(command, Command.from_dict({"tag": "a", "body": "Noop"}))
         self.assertEqual(remaining, b"<remaining>")
 
     def test_command_without_remaining(self):
         buffer = b"a NOOP\r\n"
         remaining, command = CommandCodec.decode(buffer)
-        self.assertEqual(command, {"tag": "a", "body": "Noop"})
+        self.assertEqual(command, Command.from_dict({"tag": "a", "body": "Noop"}))
         self.assertEqual(remaining, b"")
 
     def test_command_error_incomplete(self):
diff --git a/bindings/imap-codec-python/tests/test_command_encode.py b/bindings/imap-codec-python/tests/test_command_encode.py
index c51d7e61..b75f9b41 100644
--- a/bindings/imap-codec-python/tests/test_command_encode.py
+++ b/bindings/imap-codec-python/tests/test_command_encode.py
@@ -1,33 +1,44 @@
 import unittest
 
-from imap_codec import CommandCodec, Encoded, LineFragment, LiteralFragment, LiteralMode
+from imap_codec import (
+    Command,
+    CommandCodec,
+    Encoded,
+    LineFragment,
+    LiteralFragment,
+    LiteralMode,
+)
 
 
 class TestCommandEncode(unittest.TestCase):
     def test_simple_command(self):
-        command = {"tag": "a", "body": "Noop"}
+        command = Command.from_dict({"tag": "a", "body": "Noop"})
         encoded = CommandCodec.encode(command)
         self.assertIsInstance(encoded, Encoded)
         fragments = list(encoded)
         self.assertEqual(fragments, [LineFragment(b"a NOOP\r\n")])
 
     def test_simple_command_dump(self):
-        command = {"tag": "a", "body": "Noop"}
+        command = Command.from_dict({"tag": "a", "body": "Noop"})
         encoded = CommandCodec.encode(command)
         self.assertIsInstance(encoded, Encoded)
         self.assertEqual(encoded.dump(), b"a NOOP\r\n")
 
-    _MULTI_FRAGMENT_COMMAND = {
-        "tag": "A",
-        "body": {
-            "Login": {
-                "username": {"Atom": "alice"},
-                "password": {
-                    "String": {"Literal": {"data": list(b"\xCA\xFE"), "mode": "Sync"}}
-                },
-            }
-        },
-    }
+    _MULTI_FRAGMENT_COMMAND = Command.from_dict(
+        {
+            "tag": "A",
+            "body": {
+                "Login": {
+                    "username": {"Atom": "alice"},
+                    "password": {
+                        "String": {
+                            "Literal": {"data": list(b"\xCA\xFE"), "mode": "Sync"}
+                        }
+                    },
+                }
+            },
+        }
+    )
 
     def test_multi_fragment_command(self):
         encoded = CommandCodec.encode(self._MULTI_FRAGMENT_COMMAND)
diff --git a/bindings/imap-codec-python/tests/test_greeting_decode.py b/bindings/imap-codec-python/tests/test_greeting_decode.py
index 10f436e8..2d23d04d 100644
--- a/bindings/imap-codec-python/tests/test_greeting_decode.py
+++ b/bindings/imap-codec-python/tests/test_greeting_decode.py
@@ -1,6 +1,6 @@
 import unittest
 
-from imap_codec import DecodeFailed, DecodeIncomplete, GreetingCodec
+from imap_codec import DecodeFailed, DecodeIncomplete, Greeting, GreetingCodec
 
 
 class TestGreetingDecode(unittest.TestCase):
@@ -8,7 +8,8 @@ def test_greeting(self):
         buffer = b"* OK Hello, World!\r\n<remaining>"
         remaining, greeting = GreetingCodec.decode(buffer)
         self.assertEqual(
-            greeting, {"code": None, "kind": "Ok", "text": "Hello, World!"}
+            greeting,
+            Greeting.from_dict({"code": None, "kind": "Ok", "text": "Hello, World!"}),
         )
         self.assertEqual(remaining, b"<remaining>")
 
@@ -16,7 +17,10 @@ def test_greeting_with_code(self):
         buffer = b"* OK [ALERT] Hello, World!\r\n<remaining>"
         remaining, greeting = GreetingCodec.decode(buffer)
         self.assertEqual(
-            greeting, {"code": "Alert", "kind": "Ok", "text": "Hello, World!"}
+            greeting,
+            Greeting.from_dict(
+                {"code": "Alert", "kind": "Ok", "text": "Hello, World!"}
+            ),
         )
         self.assertEqual(remaining, b"<remaining>")
 
@@ -24,7 +28,8 @@ def test_greeting_without_remaining(self):
         buffer = b"* OK Hello, World!\r\n"
         remaining, greeting = GreetingCodec.decode(buffer)
         self.assertEqual(
-            greeting, {"code": None, "kind": "Ok", "text": "Hello, World!"}
+            greeting,
+            Greeting.from_dict({"code": None, "kind": "Ok", "text": "Hello, World!"}),
         )
         self.assertEqual(remaining, b"")
 
diff --git a/bindings/imap-codec-python/tests/test_greeting_encode.py b/bindings/imap-codec-python/tests/test_greeting_encode.py
index b64716fb..2b0a104f 100644
--- a/bindings/imap-codec-python/tests/test_greeting_encode.py
+++ b/bindings/imap-codec-python/tests/test_greeting_encode.py
@@ -1,18 +1,22 @@
 import unittest
 
-from imap_codec import Encoded, GreetingCodec, LineFragment
+from imap_codec import Encoded, Greeting, GreetingCodec, LineFragment
 
 
 class TestGreetingEncode(unittest.TestCase):
     def test_simple_greeting(self):
-        greeting = {"code": None, "kind": "Ok", "text": "Hello, World!"}
+        greeting = Greeting.from_dict(
+            {"code": None, "kind": "Ok", "text": "Hello, World!"}
+        )
         encoded = GreetingCodec.encode(greeting)
         self.assertIsInstance(encoded, Encoded)
         fragments = list(encoded)
         self.assertEqual(fragments, [LineFragment(b"* OK Hello, World!\r\n")])
 
     def test_simple_greeting_dump(self):
-        greeting = {"code": None, "kind": "Ok", "text": "Hello, World!"}
+        greeting = Greeting.from_dict(
+            {"code": None, "kind": "Ok", "text": "Hello, World!"}
+        )
         encoded = GreetingCodec.encode(greeting)
         self.assertIsInstance(encoded, Encoded)
         self.assertEqual(encoded.dump(), b"* OK Hello, World!\r\n")
diff --git a/bindings/imap-codec-python/tests/test_idle_done_decode.py b/bindings/imap-codec-python/tests/test_idle_done_decode.py
index c42a2356..03c19526 100644
--- a/bindings/imap-codec-python/tests/test_idle_done_decode.py
+++ b/bindings/imap-codec-python/tests/test_idle_done_decode.py
@@ -1,19 +1,19 @@
 import unittest
 
-from imap_codec import DecodeFailed, DecodeIncomplete, IdleDoneCodec
+from imap_codec import DecodeFailed, DecodeIncomplete, IdleDone, IdleDoneCodec
 
 
 class TestIdleDoneDecode(unittest.TestCase):
     def test_idle_done(self):
         buffer = b"done\r\n<remaining>"
         remaining, idle_done = IdleDoneCodec.decode(buffer)
-        self.assertEqual(idle_done, ())
+        self.assertEqual(idle_done, IdleDone())
         self.assertEqual(remaining, b"<remaining>")
 
     def test_idle_done_without_remaining(self):
         buffer = b"done\r\n"
         remaining, idle_done = IdleDoneCodec.decode(buffer)
-        self.assertEqual(idle_done, ())
+        self.assertEqual(idle_done, IdleDone())
         self.assertEqual(remaining, b"")
 
     def test_idle_done_error_incomplete(self):
diff --git a/bindings/imap-codec-python/tests/test_idle_done_encode.py b/bindings/imap-codec-python/tests/test_idle_done_encode.py
index 2e15ced3..c0dff14b 100644
--- a/bindings/imap-codec-python/tests/test_idle_done_encode.py
+++ b/bindings/imap-codec-python/tests/test_idle_done_encode.py
@@ -1,18 +1,18 @@
 import unittest
 
-from imap_codec import Encoded, IdleDoneCodec, LineFragment
+from imap_codec import Encoded, IdleDone, IdleDoneCodec, LineFragment
 
 
 class TestIdleDoneEncode(unittest.TestCase):
     def test_idle_done(self):
-        idle_done = ()
+        idle_done = IdleDone()
         encoded = IdleDoneCodec.encode(idle_done)
         self.assertIsInstance(encoded, Encoded)
         fragments = list(encoded)
         self.assertEqual(fragments, [LineFragment(b"DONE\r\n")])
 
     def test_idle_done_dump(self):
-        idle_done = ()
+        idle_done = IdleDone()
         encoded = IdleDoneCodec.encode(idle_done)
         self.assertIsInstance(encoded, Encoded)
         self.assertEqual(encoded.dump(), b"DONE\r\n")
diff --git a/bindings/imap-codec-python/tests/test_messages.py b/bindings/imap-codec-python/tests/test_messages.py
new file mode 100644
index 00000000..8936ada1
--- /dev/null
+++ b/bindings/imap-codec-python/tests/test_messages.py
@@ -0,0 +1,133 @@
+import unittest
+
+from imap_codec import AuthenticateData, Command, Greeting, IdleDone, Response
+
+
+class TestGreeting(unittest.TestCase):
+    def test_from_dict(self):
+        self.assertIsInstance(
+            Greeting.from_dict(
+                {"code": "Alert", "kind": "Ok", "text": "Hello, World!"}
+            ),
+            Greeting,
+        )
+
+        self.assertIsInstance(
+            Greeting.from_dict({"code": None, "kind": "Ok", "text": "Hello, World!"}),
+            Greeting,
+        )
+
+        with self.assertRaises(RuntimeError) as cm:
+            Greeting.from_dict({"text": "Hello, World!"})
+        self.assertEqual(str(cm.exception), "missing field `kind`")
+
+    def test_as_dict(self):
+        dictionary = {"code": "Alert", "kind": "Ok", "text": "Hello, World!"}
+        self.assertEqual(Greeting.from_dict(dictionary).as_dict(), dictionary)
+
+        dictionary = {"code": None, "kind": "Ok", "text": "Hello, World!"}
+        self.assertEqual(Greeting.from_dict(dictionary).as_dict(), dictionary)
+
+        self.assertEqual(
+            Greeting.from_dict({"kind": "Ok", "text": "Hello, World!"}).as_dict(),
+            {"code": None, "kind": "Ok", "text": "Hello, World!"},
+        )
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(
+                Greeting.from_dict(
+                    {"code": "Alert", "kind": "Ok", "text": "Hello, World!"}
+                )
+            ),
+            "Greeting({'kind': 'Ok', 'code': 'Alert', 'text': 'Hello, World!'})",
+        )
+
+        self.assertEqual(
+            repr(Greeting.from_dict({"kind": "Ok", "text": "Hello, World!"})),
+            "Greeting({'kind': 'Ok', 'code': None, 'text': 'Hello, World!'})",
+        )
+
+
+class TestCommand(unittest.TestCase):
+    def test_from_dict(self):
+        self.assertIsInstance(
+            Command.from_dict({"tag": "a", "body": "Noop"}),
+            Command,
+        )
+
+        with self.assertRaises(RuntimeError) as cm:
+            Command.from_dict({"body": "Noop"})
+        self.assertEqual(str(cm.exception), "missing field `tag`")
+
+    def test_as_dict(self):
+        dictionary = {"tag": "a", "body": "Noop"}
+        self.assertEqual(Command.from_dict(dictionary).as_dict(), dictionary)
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(Command.from_dict({"tag": "a", "body": "Noop"})),
+            "Command({'tag': 'a', 'body': 'Noop'})",
+        )
+
+
+class TestAuthenticateData(unittest.TestCase):
+    def test_from_dict(self):
+        self.assertIsInstance(
+            AuthenticateData.from_dict({"Continue": list(b"Test")}),
+            AuthenticateData,
+        )
+        self.assertIsInstance(
+            AuthenticateData.from_dict({"Cancel": {}}),
+            AuthenticateData,
+        )
+
+    def test_as_dict(self):
+        dictionary = {"Continue": list(b"Test")}
+        self.assertEqual(AuthenticateData.from_dict(dictionary).as_dict(), dictionary)
+
+        dictionary = {"Cancel": {}}
+        self.assertEqual(AuthenticateData.from_dict(dictionary).as_dict(), dictionary)
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(AuthenticateData.from_dict({"Continue": list(b"Test")})),
+            "AuthenticateData({'Continue': [84, 101, 115, 116]})",
+        )
+
+        self.assertEqual(
+            repr(AuthenticateData.from_dict({"Cancel": {}})),
+            "AuthenticateData({'Cancel': {}})",
+        )
+
+
+class TestResponse(unittest.TestCase):
+    def test_from_dict(self):
+        self.assertIsInstance(
+            Response.from_dict({"Data": {"Search": [1]}}),
+            Response,
+        )
+
+    def test_as_dict(self):
+        dictionary = {"Data": {"Search": [1]}}
+        self.assertEqual(Response.from_dict(dictionary).as_dict(), dictionary)
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(Response.from_dict({"Data": {"Search": [1]}})),
+            "Response({'Data': {'Search': [1]}})",
+        )
+
+
+class TestIdleDone(unittest.TestCase):
+    def test_new(self):
+        self.assertIsInstance(
+            IdleDone(),
+            IdleDone,
+        )
+
+    def test_repr(self):
+        self.assertEqual(
+            repr(IdleDone()),
+            "IdleDone",
+        )
diff --git a/bindings/imap-codec-python/tests/test_response_decode.py b/bindings/imap-codec-python/tests/test_response_decode.py
index 54d112e7..3f0407c9 100644
--- a/bindings/imap-codec-python/tests/test_response_decode.py
+++ b/bindings/imap-codec-python/tests/test_response_decode.py
@@ -1,19 +1,25 @@
 import unittest
 
-from imap_codec import DecodeFailed, DecodeIncomplete, DecodeLiteralFound, ResponseCodec
+from imap_codec import (
+    DecodeFailed,
+    DecodeIncomplete,
+    DecodeLiteralFound,
+    Response,
+    ResponseCodec,
+)
 
 
 class TestResponseDecode(unittest.TestCase):
     def test_response(self):
         buffer = b"* SEARCH 1\r\n<remaining>"
         remaining, response = ResponseCodec.decode(buffer)
-        self.assertEqual(response, {"Data": {"Search": [1]}})
+        self.assertEqual(response, Response.from_dict({"Data": {"Search": [1]}}))
         self.assertEqual(remaining, b"<remaining>")
 
     def test_response_without_remaining(self):
         buffer = b"* SEARCH 1\r\n"
         remaining, response = ResponseCodec.decode(buffer)
-        self.assertEqual(response, {"Data": {"Search": [1]}})
+        self.assertEqual(response, Response.from_dict({"Data": {"Search": [1]}}))
         self.assertEqual(remaining, b"")
 
     def test_response_error_incomplete(self):
diff --git a/bindings/imap-codec-python/tests/test_response_encode.py b/bindings/imap-codec-python/tests/test_response_encode.py
index 7e30c141..4291e21e 100644
--- a/bindings/imap-codec-python/tests/test_response_encode.py
+++ b/bindings/imap-codec-python/tests/test_response_encode.py
@@ -5,45 +5,48 @@
     LineFragment,
     LiteralFragment,
     LiteralMode,
+    Response,
     ResponseCodec,
 )
 
 
 class TestResponseEncode(unittest.TestCase):
     def test_simple_response(self):
-        response = {"Data": {"Search": [1]}}
+        response = Response.from_dict({"Data": {"Search": [1]}})
         encoded = ResponseCodec.encode(response)
         self.assertIsInstance(encoded, Encoded)
         fragments = list(encoded)
         self.assertEqual(fragments, [LineFragment(b"* SEARCH 1\r\n")])
 
     def test_simple_response_dump(self):
-        response = {"Data": {"Search": [1]}}
+        response = Response.from_dict({"Data": {"Search": [1]}})
         encoded = ResponseCodec.encode(response)
         self.assertIsInstance(encoded, Encoded)
         self.assertEqual(encoded.dump(), b"* SEARCH 1\r\n")
 
-    _MULTI_FRAGMENT_RESPONSE = {
-        "Data": {
-            "Fetch": {
-                "seq": 12345,
-                "items": [
-                    {
-                        "BodyExt": {
-                            "section": None,
-                            "origin": None,
-                            "data": {
-                                "Literal": {
-                                    "data": list(b"ABCDE"),
-                                    "mode": "NonSync",
-                                }
-                            },
+    _MULTI_FRAGMENT_RESPONSE = Response.from_dict(
+        {
+            "Data": {
+                "Fetch": {
+                    "seq": 12345,
+                    "items": [
+                        {
+                            "BodyExt": {
+                                "section": None,
+                                "origin": None,
+                                "data": {
+                                    "Literal": {
+                                        "data": list(b"ABCDE"),
+                                        "mode": "NonSync",
+                                    }
+                                },
+                            }
                         }
-                    }
-                ],
-            }
-        },
-    }
+                    ],
+                }
+            },
+        }
+    )
 
     def test_multi_fragment_response(self):
         encoded = ResponseCodec.encode(self._MULTI_FRAGMENT_RESPONSE)