Repository: gtalarico/pyairtable
Pull request #287 (2023-07-31 19:09:41)

Introduce "Collaborator" model
The comments branch (#282) had an annoying little quirk where mentioned users were returned as type-annotated models, but the author information was returned as a dict instead. This makes it consistent and adds a few docs/tests.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) Report
> Merging [#287](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) (f9bfe96) into [main](https://app.codecov.io/gh/gtalarico/pyairtable/commit/a3b471a220712e507c58f0ffcbf6bdfeac8083d0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) (a3b471a) will **increase** coverage by `0.07%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##             main     #287      +/-   ##
==========================================
+ Coverage   95.75%   95.82%   +0.07%     
==========================================
  Files          18       19       +1     
  Lines         919      935      +16     
==========================================
+ Hits          880      896      +16     
  Misses         39       39              
```


| [Files Changed](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) | Coverage Δ | |
|---|---|---|
| [pyairtable/api/table.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9hcGkvdGFibGUucHk=) | `98.07% <ø> (ø)` | |
| [pyairtable/models/\_\_init\_\_.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9tb2RlbHMvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [pyairtable/models/collaborator.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9tb2RlbHMvY29sbGFib3JhdG9yLnB5) | `100.00% <100.00%> (ø)` | |
| [pyairtable/models/comment.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/287?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9tb2RlbHMvY29tbWVudC5weQ==) | `100.00% <100.00%> (ø)` | |



Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index c5a7fbf..fd97cc1 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -38,6 +38,8 @@ Module: pyairtable.models
 
 .. autoclass:: pyairtable.models.Comment
     :members:
+.. autoclass:: pyairtable.models.Collaborator
+    :members:
 
 Module: pyairtable.orm
 *******************************
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index a4eca88..035d3f9 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -348,11 +348,11 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi
                     type='user'
                 )
             },
-            author={
-                'id': 'usr0000pyairtable',
-                'email': 'pyairtable@example.com',
-                'name': 'Your pyairtable access token'
-            }
+            author=Collaborator(
+                id='usr0000pyairtable',
+                email='pyairtable@example.com',
+                name='Your pyairtable access token'
+            )
         )
     ]
     >>> comment.text = "Never mind!"
diff --git a/docs/source/tables.rst b/docs/source/tables.rst
index b2f79a0..2b5a0fe 100644
--- a/docs/source/tables.rst
+++ b/docs/source/tables.rst
@@ -297,11 +297,11 @@ and :meth:`~pyairtable.Table.add_comment` methods will return instances of
                     type='user'
                 )
             },
-            author={
-                'id': 'usr0000pyairtable',
-                'email': 'pyairtable@example.com',
-                'name': 'Your pyairtable access token'
-            }
+            author=Collaborator(
+                id='usr0000pyairtable',
+                email='pyairtable@example.com',
+                name='Your pyairtable access token'
+            )
         )
     ]
     >>> comment.text = "Never mind!"
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index e149cc1..94b07d6 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -489,11 +489,11 @@ class Table:
                             type='user'
                         )
                     },
-                    author={
-                        'id': 'usr0000pyairtable',
-                        'email': 'pyairtable@example.com',
-                        'name': 'Your pyairtable access token'
-                    }
+                    author=Collaborator(
+                        id='usr0000pyairtable',
+                        email='pyairtable@example.com',
+                        name='Your pyairtable access token'
+                    )
                 )
             ]
 
diff --git a/pyairtable/models/__init__.py b/pyairtable/models/__init__.py
index 13af15d..0f96c30 100644
--- a/pyairtable/models/__init__.py
+++ b/pyairtable/models/__init__.py
@@ -1,5 +1,7 @@
+from .collaborator import Collaborator
 from .comment import Comment
 
 __all__ = [
+    "Collaborator",
     "Comment",
 ]
diff --git a/pyairtable/models/collaborator.py b/pyairtable/models/collaborator.py
new file mode 100644
index 0000000..a55c033
--- /dev/null
+++ b/pyairtable/models/collaborator.py
@@ -0,0 +1,26 @@
+from typing import Optional
+
+from typing_extensions import TypeAlias
+
+from ._base import AirtableModel
+
+UserId: TypeAlias = str
+
+
+class Collaborator(AirtableModel):
+    """
+    Represents an Airtable user being passed from the API.
+
+    This is only used in contexts involving other models (e.g. :class:`~pyairtable.models.Comment`).
+    In contexts where API values are returned as ``dict``, we will return
+    collaborator information as a ``dict`` as well.
+    """
+
+    #: Airtable's unique ID for the user, in the format ``usrXXXXXXXXXXXXXX``.
+    id: UserId
+
+    #: The email address of the user.
+    email: Optional[str]
+
+    #: The display name of the user.
+    name: Optional[str]
diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py
index a5c5f40..88821ab 100644
--- a/pyairtable/models/comment.py
+++ b/pyairtable/models/comment.py
@@ -1,7 +1,7 @@
 from typing import Dict, Optional
 
-from pyairtable.api.types import CollaboratorDict
-from pyairtable.models._base import AirtableModel, SerializableModel
+from ._base import AirtableModel, SerializableModel
+from .collaborator import Collaborator
 
 
 class Comment(SerializableModel):
@@ -24,11 +24,11 @@ class Comment(SerializableModel):
                     type='user'
                 )
             },
-            author={
-                'id': 'usrL2xZC5xoH4luAi',
-                'email': 'pyairtable@example.com',
-                'name': 'Your pyairtable access token'
-            }
+            author=Collaborator(
+                id='usr0000pyairtable',
+                email='pyairtable@example.com',
+                name='Your pyairtable access token'
+            )
         )
     ]
     >>> comment.text = "Never mind!"
@@ -51,7 +51,7 @@ class Comment(SerializableModel):
     last_updated_time: Optional[str]
 
     #: The account which created the comment.
-    author: CollaboratorDict
+    author: Collaborator
 
     #: Users or groups that were mentioned in the text.
     mentioned: Optional[Dict[str, "Comment.Mentioned"]]


Test patch:
diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py
index 6c6e1e4..b8b994f 100644
--- a/tests/integration/test_integration_api.py
+++ b/tests/integration/test_integration_api.py
@@ -311,7 +311,7 @@ def test_integration_comments(api, table: Table, cols):
     comments = table.comments(record["id"])
     assert len(comments) == 1
     assert whoami in comments[0].text
-    assert comments[0].author
+    assert comments[0].author.id == whoami
     assert comments[0].mentioned[whoami].id == whoami
 
     # Test that we can modify the comment and examine its updated state
diff --git a/tests/test_models_collaborator.py b/tests/test_models_collaborator.py
new file mode 100644
index 0000000..a163047
--- /dev/null
+++ b/tests/test_models_collaborator.py
@@ -0,0 +1,29 @@
+import pytest
+
+from pyairtable.models import Collaborator
+
+fake_user_data = {
+    "id": "usr000000fakeuser",
+    "email": "fake@example.com",
+    "name": "Fake User",
+}
+
+
+def test_parse():
+    user = Collaborator.parse_obj(fake_user_data)
+    assert user.id == fake_user_data["id"]
+    assert user.email == fake_user_data["email"]
+    assert user.name == fake_user_data["name"]
+
+
+def test_init():
+    c = Collaborator(id="usrXXXXXXXXXXXXX")
+    assert c.id == "usrXXXXXXXXXXXXX"
+    assert c.email is None
+    assert c.name is None
+
+    with pytest.raises(ValueError):
+        Collaborator()
+
+    with pytest.raises(ValueError):
+        Collaborator(name="Fake User")
diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py
index 25ab33c..45564c6 100644
--- a/tests/test_models_comment.py
+++ b/tests/test_models_comment.py
@@ -45,6 +45,16 @@ def test_parse(comment_json):
     Comment.parse_obj(comment_json)
 
 
+@pytest.mark.parametrize("attr", ["mentioned", "last_updated_time"])
+def test_missing_attributes(comment_json, attr):
+    """
+    Test that we can parse the payload when missing optional values.
+    """
+    del comment_json[Comment.__fields__[attr].alias]
+    comment = Comment.parse_obj(comment_json)
+    assert getattr(comment, attr) is None
+
+
 @pytest.mark.parametrize(
     "attr,value",
     [
@@ -82,6 +92,7 @@ def test_save(comment, requests_mock):
 
     # ...but our model loaded whatever values the API sent back.
     assert comment.text == new_text
+    assert comment.author.email == "author@example.com"
     assert not comment.mentioned