Repository: gtalarico/pyairtable
Pull request #374 (2024-05-21 16:27:21)

Documentation and API cleanup ahead of 3.0 release
There are a couple breaking name changes here (like moving exceptions into `pyairtable.exceptions`) along with some cleanups on the API documentation itself.


Patch:
diff --git a/docs/source/api.rst b/docs/source/api.rst
index 2c0d9ba..fcfa1aa 100644
--- a/docs/source/api.rst
+++ b/docs/source/api.rst
@@ -27,6 +27,14 @@ API: pyairtable
 .. autofunction:: pyairtable.retry_strategy
 
 
+API: pyairtable.api.enterprise
+*******************************
+
+.. automodule:: pyairtable.api.enterprise
+    :members:
+    :exclude-members: Enterprise
+
+
 API: pyairtable.api.types
 *******************************
 
@@ -34,6 +42,13 @@ API: pyairtable.api.types
     :members:
 
 
+API: pyairtable.exceptions
+*******************************
+
+.. automodule:: pyairtable.exceptions
+    :members:
+
+
 API: pyairtable.formulas
 *******************************
 
@@ -49,19 +64,28 @@ API: pyairtable.models
     :inherited-members: AirtableModel
 
 
-API: pyairtable.models.audit
-********************************
+API: pyairtable.models.comment
+-------------------------------
 
-.. automodule:: pyairtable.models.audit
+.. automodule:: pyairtable.models.comment
     :members:
+    :exclude-members: Comment
     :inherited-members: AirtableModel
 
 
 API: pyairtable.models.schema
-********************************
+-------------------------------
 
 .. automodule:: pyairtable.models.schema
     :members:
+
+
+API: pyairtable.models.webhook
+-------------------------------
+
+.. automodule:: pyairtable.models.webhook
+    :members:
+    :exclude-members: Webhook, WebhookNotification, WebhookPayload
     :inherited-members: AirtableModel
 
 
@@ -81,6 +105,13 @@ API: pyairtable.orm.fields
     :no-inherited-members:
 
 
+API: pyairtable.testing
+*******************************
+
+.. automodule:: pyairtable.testing
+    :members:
+
+
 API: pyairtable.utils
 *******************************
 
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index 79245b4..4cbae28 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -76,13 +76,16 @@ The 3.0 release has changed the API for retrieving ORM model configuration:
 Miscellaneous name changes
 ---------------------------------------------
 
-.. list-table::
-    :header-rows: 1
-
-    * - Old name
-      - New name
-    * - :class:`~pyairtable.api.enterprise.ClaimUsersResponse`
-      - :class:`~pyairtable.api.enterprise.ManageUsersResponse`
+    * - | ``pyairtable.api.enterprise.ClaimUsersResponse``
+        | has become :class:`pyairtable.api.enterprise.ManageUsersResponse`
+    * - | ``pyairtable.formulas.CircularDependency``
+        | has become :class:`pyairtable.exceptions.CircularFormulaError`
+    * - | ``pyairtable.params.InvalidParamException``
+        | has become :class:`pyairtable.exceptions.InvalidParameterError`
+    * - | ``pyairtable.orm.fields.MissingValue``
+        | has become :class:`pyairtable.exceptions.MissingValueError`
+    * - | ``pyairtable.orm.fields.MultipleValues``
+        | has become :class:`pyairtable.exceptions.MultipleValuesError`
 
 
 Migrating from 2.2 to 2.3
diff --git a/pyairtable/api/params.py b/pyairtable/api/params.py
index 10a09e8..07c7a2b 100644
--- a/pyairtable/api/params.py
+++ b/pyairtable/api/params.py
@@ -1,10 +1,6 @@
 from typing import Any, Dict, List, Tuple
 
-
-class InvalidParamException(ValueError):
-    """
-    Raised when invalid parameters are passed to ``all()``, ``first()``, etc.
-    """
+from pyairtable.exceptions import InvalidParameterError
 
 
 def dict_list_to_request_params(
@@ -85,7 +81,7 @@ def _option_to_param(name: str) -> str:
     try:
         return OPTIONS_TO_PARAMETERS[name]
     except KeyError:
-        raise InvalidParamException(name)
+        raise InvalidParameterError(name)
 
 
 #: List of option names that cannot be passed via POST, only GET
diff --git a/pyairtable/api/retrying.py b/pyairtable/api/retrying.py
index 3a33bc5..893714b 100644
--- a/pyairtable/api/retrying.py
+++ b/pyairtable/api/retrying.py
@@ -74,5 +74,5 @@ class _RetryingSession(Session):
 
 __all__ = [
     "Retry",
-    "_RetryingSession",
+    "retry_strategy",
 ]
diff --git a/pyairtable/api/workspace.py b/pyairtable/api/workspace.py
index 3da75fd..ed1cc45 100644
--- a/pyairtable/api/workspace.py
+++ b/pyairtable/api/workspace.py
@@ -104,9 +104,9 @@ class Workspace:
         See https://airtable.com/developers/web/api/move-base
 
         Usage:
+            >>> base = api.base("appCwFmhESAta6clC")
             >>> ws = api.workspace("wspmhESAta6clCCwF")
-            >>> base = api.workspace("appCwFmhESAta6clC")
-            >>> workspace.move_base(base, "wspSomeOtherPlace", index=0)
+            >>> ws.move_base(base, "wspSomeOtherPlace", index=0)
         """
         base_id = base if isinstance(base, str) else base.id
         target_id = target if isinstance(target, str) else target.id
diff --git a/pyairtable/exceptions.py b/pyairtable/exceptions.py
new file mode 100644
index 0000000..74360c7
--- /dev/null
+++ b/pyairtable/exceptions.py
@@ -0,0 +1,28 @@
+class PyAirtableError(Exception):
+    """
+    Base class for all exceptions raised by PyAirtable.
+    """
+
+
+class CircularFormulaError(PyAirtableError, RecursionError):
+    """
+    A circular dependency was encountered when flattening nested conditions.
+    """
+
+
+class InvalidParameterError(PyAirtableError, ValueError):
+    """
+    Raised when invalid parameters are passed to ``all()``, ``first()``, etc.
+    """
+
+
+class MissingValueError(PyAirtableError, ValueError):
+    """
+    A required field received an empty value, either from Airtable or other code.
+    """
+
+
+class MultipleValuesError(PyAirtableError, ValueError):
+    """
+    SingleLinkField received more than one value from either Airtable or calling code.
+    """
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index da3bbee..dac7ad5 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -14,6 +14,7 @@ from typing import Any, ClassVar, Iterable, List, Optional, Set, Union
 from typing_extensions import Self as SelfType
 
 from pyairtable.api.types import Fields
+from pyairtable.exceptions import CircularFormulaError
 from pyairtable.utils import date_to_iso_str, datetime_to_iso_str
 
 
@@ -235,7 +236,7 @@ class Compound(Formula):
         flattened: List[Formula] = []
         for item in self.components:
             if id(item) in memo:
-                raise CircularDependency(item)
+                raise CircularFormulaError(item)
             if isinstance(item, Compound) and item.operator == self.operator:
                 flattened.extend(item.flatten(memo=memo).components)
             else:
@@ -315,12 +316,6 @@ def NOT(component: Optional[Formula] = None, /, **fields: Any) -> Compound:
     return Compound.build("NOT", items)
 
 
-class CircularDependency(RecursionError):
-    """
-    A circular dependency was encountered when flattening nested conditions.
-    """
-
-
 def match(field_values: Fields, *, match_any: bool = False) -> Formula:
     r"""
     Create one or more equality expressions for each provided value,
diff --git a/pyairtable/models/__init__.py b/pyairtable/models/__init__.py
index 0ddaa61..765a613 100644
--- a/pyairtable/models/__init__.py
+++ b/pyairtable/models/__init__.py
@@ -6,13 +6,20 @@
 pyAirtable will wrap certain API responses in type-annotated models,
 some of which will be deeply nested within each other. Models which
 implementers can interact with directly are documented below.
+Nested or internal models are documented in each submodule.
+
+Due to its complexity, the :mod:`pyairtable.models.schema` module is
+documented separately, and none of its classes are exposed here.
 """
 
+from .audit import AuditLogEvent, AuditLogResponse
 from .collaborator import Collaborator
 from .comment import Comment
 from .webhook import Webhook, WebhookNotification, WebhookPayload
 
 __all__ = [
+    "AuditLogResponse",
+    "AuditLogEvent",
     "Collaborator",
     "Comment",
     "Webhook",
diff --git a/pyairtable/models/comment.py b/pyairtable/models/comment.py
index 3769e2c..aa6adb4 100644
--- a/pyairtable/models/comment.py
+++ b/pyairtable/models/comment.py
@@ -1,6 +1,8 @@
 from datetime import datetime
 from typing import Dict, Optional
 
+from pyairtable._compat import pydantic
+
 from ._base import AirtableModel, CanDeleteModel, CanUpdateModel, update_forward_refs
 from .collaborator import Collaborator
 
@@ -58,7 +60,7 @@ class Comment(
     author: Collaborator
 
     #: Users or groups that were mentioned in the text.
-    mentioned: Optional[Dict[str, "Mentioned"]]
+    mentioned: Dict[str, "Mentioned"] = pydantic.Field(default_factory=dict)
 
 
 class Mentioned(AirtableModel):
diff --git a/pyairtable/models/record.py b/pyairtable/models/record.py
deleted file mode 100644
index e69de29..0000000
diff --git a/pyairtable/models/schema.py b/pyairtable/models/schema.py
index 214ca30..4d02651 100644
--- a/pyairtable/models/schema.py
+++ b/pyairtable/models/schema.py
@@ -1345,7 +1345,11 @@ class _HasFieldSchema(AirtableModel):
     field_schema: FieldSchema
 
 
-def parse_field_schema(obj: Any) -> FieldSchema:
+def parse_field_schema(obj: Dict[str, Any]) -> FieldSchema:
+    """
+    Given a ``dict`` representing a field schema,
+    parse it into the appropriate FieldSchema subclass.
+    """
     return _HasFieldSchema.parse_obj({"field_schema": obj}).field_schema
 
 
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 1b92d6a..e7ca445 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -57,6 +57,7 @@ from pyairtable.api.types import (
     CollaboratorDict,
     RecordId,
 )
+from pyairtable.exceptions import MissingValueError, MultipleValuesError
 
 if TYPE_CHECKING:
     from pyairtable.orm import Model  # noqa
@@ -278,21 +279,15 @@ class _FieldWithRequiredValue(Generic[T_API, T_ORM], Field[T_API, T_ORM, T_ORM])
     ) -> Union[SelfType, T_ORM]:
         value = super().__get__(instance, owner)
         if value is None or value == "":
-            raise MissingValue(f"{self._description} received an empty value")
+            raise MissingValueError(f"{self._description} received an empty value")
         return value
 
     def __set__(self, instance: "Model", value: Optional[T_ORM]) -> None:
         if value in (None, ""):
-            raise MissingValue(f"{self._description} does not accept empty values")
+            raise MissingValueError(f"{self._description} does not accept empty values")
         super().__set__(instance, value)
 
 
-class MissingValue(ValueError):
-    """
-    A required field received an empty value, either from Airtable or other code.
-    """
-
-
 #: A generic Field with internal and API representations that are the same type.
 _BasicField: TypeAlias = Field[T, T, None]
 _BasicFieldWithMissingValue: TypeAlias = Field[T, T, T]
@@ -810,7 +805,9 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
         if not instance:
             return self
         if self._raise_if_many and len(instance._fields.get(self.field_name) or []) > 1:
-            raise MultipleValues(f"{self._description} got more than one linked record")
+            raise MultipleValuesError(
+                f"{self._description} got more than one linked record"
+            )
         links = self._link_field.__get__(instance, owner)
         try:
             return links[0]
@@ -844,12 +841,6 @@ class SingleLinkField(Generic[T_Linked], Field[List[str], T_Linked, None]):
         return self._link_field.linked_model
 
 
-class MultipleValues(ValueError):
-    """
-    SingleLinkField received more than one value from either Airtable or calling code.
-    """
-
-
 # Many of these are "passthrough" subclasses for now. E.g. there is no real
 # difference between `field = TextField()` and `field = PhoneNumberField()`.
 #


Test patch:
diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py
index 288621a..65265e1 100644
--- a/tests/integration/test_integration_api.py
+++ b/tests/integration/test_integration_api.py
@@ -313,7 +313,7 @@ def test_integration_comments(api, table: Table, cols):
     comments[0].text = "Never mind!"
     comments[0].save()
     assert whoami not in comments[0].text
-    assert comments[0].mentioned is None
+    assert not comments[0].mentioned
 
     # Test that we can delete the comment
     comments[0].delete()
diff --git a/tests/test_formulas.py b/tests/test_formulas.py
index 0eb8a3a..41396da 100644
--- a/tests/test_formulas.py
+++ b/tests/test_formulas.py
@@ -5,6 +5,7 @@ from fractions import Fraction
 import pytest
 from mock import call
 
+import pyairtable.exceptions
 from pyairtable import formulas as F
 from pyairtable import orm
 from pyairtable.formulas import AND, EQ, GT, GTE, LT, LTE, NE, NOT, OR
@@ -181,7 +182,7 @@ def test_compound_flatten():
 def test_compound_flatten_circular_dependency():
     circular = NOT(F.Formula("x"))
     circular.components = [circular]
-    with pytest.raises(F.CircularDependency):
+    with pytest.raises(pyairtable.exceptions.CircularFormulaError):
         circular.flatten()
 
 
diff --git a/tests/test_models_comment.py b/tests/test_models_comment.py
index fc4f223..79fc8b0 100644
--- a/tests/test_models_comment.py
+++ b/tests/test_models_comment.py
@@ -45,14 +45,15 @@ 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):
+def test_missing_attributes(comment_json):
     """
     Test that we can parse the payload when missing optional values.
     """
-    del comment_json[Comment.__fields__[attr].alias]
+    del comment_json["lastUpdatedTime"]
+    del comment_json["mentioned"]
     comment = Comment.parse_obj(comment_json)
-    assert getattr(comment, attr) is None
+    assert comment.mentioned == {}
+    assert comment.last_updated_time is None
 
 
 @pytest.mark.parametrize(
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 40f9355..3b68aff 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -6,6 +6,7 @@ from unittest import mock
 import pytest
 from requests_mock import NoMockAddress
 
+import pyairtable.exceptions
 from pyairtable.formulas import OR, RECORD_ID
 from pyairtable.orm import fields as f
 from pyairtable.orm.model import Model
@@ -465,11 +466,11 @@ def test_rejects_null(field_type):
         the_field = field_type("Field Name")
 
     obj = T()
-    with pytest.raises(f.MissingValue):
+    with pytest.raises(pyairtable.exceptions.MissingValueError):
         obj.the_field
-    with pytest.raises(f.MissingValue):
+    with pytest.raises(pyairtable.exceptions.MissingValueError):
         obj.the_field = None
-    with pytest.raises(f.MissingValue):
+    with pytest.raises(pyairtable.exceptions.MissingValueError):
         T(the_field=None)
 
 
@@ -855,7 +856,7 @@ def test_single_link_field__raise_if_many():
         author = f.SingleLinkField("Author", Author, raise_if_many=True)
 
     book = Book.from_record(fake_record(Author=[fake_id(), fake_id()]))
-    with pytest.raises(f.MultipleValues):
+    with pytest.raises(pyairtable.exceptions.MultipleValuesError):
         book.author
 
 
diff --git a/tests/test_params.py b/tests/test_params.py
index 89b00aa..bd23820 100644
--- a/tests/test_params.py
+++ b/tests/test_params.py
@@ -3,12 +3,12 @@ import requests
 from requests_mock import Mocker
 
 from pyairtable.api.params import (
-    InvalidParamException,
     dict_list_to_request_params,
     field_names_to_sorting_dict,
     options_to_json_and_params,
     options_to_params,
 )
+from pyairtable.exceptions import InvalidParameterError
 
 
 def test_params_integration(table, mock_records, mock_response_iterator):
@@ -178,7 +178,7 @@ def test_convert_options_to_json(option, value, expected):
 
 
 def test_process_params_invalid():
-    with pytest.raises(InvalidParamException):
+    with pytest.raises(InvalidParameterError):
         options_to_params({"ffields": "x"})