Repository: gtalarico/pyairtable
Pull request #278 (2023-06-30 05:55:12)

Release 2.0.0rc1
A few last minute cleanups on documentation and a full list of PRs in the changelog. I think this is ready to rumble. 

I am inclined to leave this open until after the US holiday, in case anyone out there has random ideas/feedback to share, or in case someone in the world has an unpinned dependency and we're about to ruin their build. 😬 


Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index efa10f6..039bcae 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -7,13 +7,26 @@ Changelog
 
 See :ref:`Migrating from 1.x to 2.0` for detailed migration notes.
 
+* :ref:`ORM` module is no longer experimental and has a stable API.
+  - `PR #277 <https://github.com/gtalarico/pyairtable/pull/277>`_.
+* Added :meth:`Model.batch_save <pyairtable.orm.Model.batch_save>`
+  and :meth:`Model.batch_delete <pyairtable.orm.Model.batch_delete>`.
+  - `PR #274 <https://github.com/gtalarico/pyairtable/pull/277>`_.
+* Added :meth:`Api.whoami <pyairtable.Api.whoami>` method.
+  - `PR #273 <https://github.com/gtalarico/pyairtable/pull/273>`_.
+* pyAirtable will automatically retry requests when throttled by Airtable's QPS.
+  - `PR #272 <https://github.com/gtalarico/pyairtable/pull/272>`_.
 * Removed ``ApiAbstract``.
+  - `PR #267 <https://github.com/gtalarico/pyairtable/pull/267>`_.
 * Implemented strict type annotations on all functions and methods.
-* Added :ref:`ORM` support for all Airtable field types.
-  - `PR #260 <https://github.com/gtalarico/pyairtable/pull/260>`_.
+  - `PR #263 <https://github.com/gtalarico/pyairtable/pull/263>`_.
 * Return Model instances, not dicts, from
   :meth:`Model.all <pyairtable.orm.Model.all>` and :meth:`Model.first <pyairtable.orm.Model.first>`.
   - `PR #262 <https://github.com/gtalarico/pyairtable/pull/262>`_.
+* Dropped support for Python 3.7.
+  - `PR #261 <https://github.com/gtalarico/pyairtable/pull/261>`_.
+* :ref:`ORM` supports all Airtable field types.
+  - `PR #260 <https://github.com/gtalarico/pyairtable/pull/260>`_.
 
 1.5.0 (2023-05-15)
 -------------------------
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 715ac66..cfcdd1e 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -6,7 +6,7 @@ pyAirtable
 
 .. container:: .large
 
-   Python Client for the `Airtable Api <https://airtable.com/api>`_
+   Python Client for the `Airtable API <https://airtable.com/api>`_
 
 .. container:: .buttons
 
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index d9d7360..cb482ef 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -7,7 +7,7 @@ Migrating from 1.x to 2.0
 
 With the 2.0 release, we've made some breaking changes to the pyAirtable API. These are summarized below.
 You can read more about the rationale behind these changes in `#257 <https://github.com/gtalarico/pyairtable/issues/257>`_,
-or you can read more about new library features in the :ref:`Changelog`.
+and you see a list of changes and new features in the :ref:`Changelog`.
 
 ApiAbstract removed
 -----------------------
@@ -87,7 +87,7 @@ New Features in 1.0
 -------------------
 
 * Type Annotations
-* Simpler Api
+* Simpler API
 * Formulas
 * ORM Models
 
@@ -96,7 +96,7 @@ API Changes in 1.0
 
 We used this new major release to make a few breaking changes:
 
-* Introduced a simpler api that's more closely aligned with Airtable Api's patterns.
+* Introduced a simpler API that's more closely aligned with Airtable API's patterns.
 * Created more a flexible API (:class:`~pyairtable.Api`, :class:`~pyairtable.Base`, :class:`~pyairtable.Table`)
 
 
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 16e1c93..66dda51 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -9,9 +9,10 @@ Defining Models
 
 The :class:`~pyairtable.orm.Model` class allows you create ORM-style classes for your Airtable tables.
 
-.. code-block: python::
+.. code-block:: python
 
     from pyairtable.orm import Model, fields as F
+
     class Contact(Model):
         first_name = F.TextField("First Name")
         last_name = F.TextField("Last Name")
@@ -106,6 +107,8 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
 
    * - ORM field class
      - Airtable field type(s)
+   * - :class:`~pyairtable.orm.fields.AttachmentsField`
+     - `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__
    * - :class:`~pyairtable.orm.fields.AutoNumberField` 🔒
      - `Auto number <https://airtable.com/developers/web/api/field-model#autonumber>`__
    * - :class:`~pyairtable.orm.fields.BarcodeField`
@@ -146,8 +149,6 @@ read `Field types and cell values <https://airtable.com/developers/web/api/field
      - `Link to another record <https://airtable.com/developers/web/api/field-model#foreignkey>`__
    * - :class:`~pyairtable.orm.fields.LookupField` 🔒
      - `Lookup <https://airtable.com/developers/web/api/field-model#lookup>`__
-   * - :class:`~pyairtable.orm.fields.MultipleAttachmentsField`
-     - `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`__
    * - :class:`~pyairtable.orm.fields.MultipleCollaboratorsField`
      - `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`__
    * - :class:`~pyairtable.orm.fields.MultipleSelectField`
@@ -231,7 +232,7 @@ traverse between related records.
 
 .. code-block:: python
 
-    from pyairtable.orm import Model, fields as F as F
+    from pyairtable.orm import Model, fields as F
 
     class Company(Model):
         class Meta: ...
@@ -282,7 +283,7 @@ address this:
 
 .. code-block:: python
 
-    from pyairtable.orm import Model, fields as F as F
+    from pyairtable.orm import Model, fields as F
 
     class Company(Model):
         class Meta: ...
diff --git a/docs/source/tables.rst b/docs/source/tables.rst
index e06082a..e7c0a5f 100644
--- a/docs/source/tables.rst
+++ b/docs/source/tables.rst
@@ -4,7 +4,7 @@ Working with Tables
 .. include:: substitutions.rst
 
 
-.. warning::
+.. note::
     Airtable imposes a `rate limit <https://airtable.com/developers/web/api/rate-limits>`__
     of 5 QPS per base. If you exceed that limit, their API will return 429 errors for a moment.
     By default, pyAirtable will retry 429 errors up to five times, but retrieving many pages
@@ -168,7 +168,7 @@ Creates a single record from a dictionary representing the table's fields.
 
 :meth:`~pyairtable.Table.batch_create`
 
-Create multiple records from a list of :class:`~pyairtable.api.types.Fields` dicts.
+Create multiple records from a list of :class:`~pyairtable.api.types.WritableFields` dicts.
 
 .. code-block:: python
 
diff --git a/pyairtable/__init__.py b/pyairtable/__init__.py
index ae784bd..1da4678 100644
--- a/pyairtable/__init__.py
+++ b/pyairtable/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "1.5.0"
+__version__ = "2.0.0rc1"
 
 from .api import Api, Base, Table
 from .api.retrying import retry_strategy
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index e919383..0dd56b8 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -3,7 +3,7 @@ pyAirtable provides a number of type aliases and TypedDicts which are used as in
 and return values to various pyAirtable methods.
 """
 from functools import lru_cache
-from typing import Any, Dict, List, Literal, Optional, Type, TypeVar, Union, cast
+from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
 
 import pydantic
 from typing_extensions import Required, TypeAlias, TypedDict
@@ -159,26 +159,19 @@ class CollaboratorEmailDict(TypedDict):
     email: str
 
 
-class FormulaErrorDict(TypedDict):
-    """
-    The dict returned by Airtable to indicate a formula error.
-    """
-
-    error: str
-
+#: Represents the types of values that we might receive from the API.
+#: At present, is an alias for ``Any`` because we don't want to lose
+#: forward compatibility with any changes Airtable makes in the future.
+FieldValue: TypeAlias = Any
 
-class FormulaNotANumberDict(TypedDict):
-    """
-    The dict returned by Airtable to indicate a NaN result.
-    """
 
-    specialValue: Literal["NaN"]
+#: A mapping of field names to values that we might receive from the API.
+Fields: TypeAlias = Dict[FieldName, FieldValue]
 
 
-#: Represents the types of values that an Airtable field could provide.
-#: For more information on Airtable field types, see
-#: `Field types and cell values <https://airtable.com/developers/web/api/field-model>`__.
-FieldValue: TypeAlias = Union[
+#: Represents the types of values that can be written to the Airtable API.
+WritableFieldValue: TypeAlias = Union[
+    None,
     str,
     int,
     float,
@@ -186,23 +179,16 @@ FieldValue: TypeAlias = Union[
     CollaboratorDict,
     CollaboratorEmailDict,
     BarcodeDict,
-    ButtonDict,
     List[str],
-    List[int],
-    List[float],
-    List[bool],
     List[AttachmentDict],
+    List[CreateAttachmentDict],
     List[CollaboratorDict],
     List[CollaboratorEmailDict],
-    FormulaErrorDict,
-    FormulaNotANumberDict,
-    List[FormulaErrorDict],
-    List[FormulaNotANumberDict],
 ]
 
 
-#: A mapping of field names to values.
-Fields: TypeAlias = Dict[FieldName, Optional[FieldValue]]
+#: A mapping of field names to values which can be sent to the API.
+WritableFields: TypeAlias = Dict[FieldName, WritableFieldValue]
 
 
 class RecordDict(TypedDict):
@@ -229,7 +215,7 @@ class CreateRecordDict(TypedDict):
     A ``dict`` representing the payload passed to the Airtable API to create a record.
     """
 
-    fields: Fields
+    fields: WritableFields
 
 
 class UpdateRecordDict(TypedDict):
@@ -245,7 +231,7 @@ class UpdateRecordDict(TypedDict):
     """
 
     id: RecordId
-    fields: Fields
+    fields: WritableFields
 
 
 class RecordDeletedDict(TypedDict):
@@ -328,7 +314,7 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T:
 
 def assert_typed_dicts(cls: Type[T], objects: Any) -> List[T]:
     """
-    Like :func:`~pyairtable.api.types.assert_typed_dict` but for a list.
+    Like :func:`~pyairtable.api.types.assert_typed_dict` but for a list of dicts.
 
     Args:
         cls: The TypedDict class.
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 077ae7c..a5bc307 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -1,16 +1,16 @@
 """
 Field are used to define the Airtable column type for your pyAirtable models.
 
-Internally these are implemented as descriptors, this allows us to proxy getting and settings values,
-while also providing a type-annotated interface.
+Internally these are implemented as `descriptors <https://docs.python.org/3/howto/descriptor.html>`_,
+which allows us to define methods and type annotations for getting and setting attribute values.
 
 >>> from pyairtable.orm import Model, fields
 >>> class Contact(Model):
+...     class Meta:
+...         ...
 ...     name = fields.TextField("Name")
 ...     is_registered = fields.CheckboxField("Registered")
 ...
-...     class Meta:
-...         ...
 >>> contact = Contact(name="George", is_registered=True)
 >>> assert contact.name == "George"
 >>> reveal_type(contact.name)  # -> str
@@ -181,12 +181,21 @@ class Field(Generic[T_API, T_ORM], metaclass=abc.ABCMeta):
         return None
 
     def to_record_value(self, value: Any) -> Any:
+        """
+        Returns the value which should be persisted to the API.
+        """
         return value
 
     def to_internal_value(self, value: Any) -> Any:
+        """
+        Converts a value from the API into the value's internal representation.
+        """
         return value
 
     def valid_or_raise(self, value: Any) -> None:
+        """
+        Validate the type of the given value and raise TypeError if invalid.
+        """
         if self.valid_types and not isinstance(value, self.valid_types):
             raise TypeError(
                 f"{self.__class__.__name__} value must be {self.valid_types}; got {type(value)}"
@@ -356,9 +365,15 @@ class DurationField(Field[int, timedelta]):
     valid_types = timedelta
 
     def to_record_value(self, value: timedelta) -> float:
+        """
+        Converts a ``timedelta`` into a number of seconds.
+        """
         return value.total_seconds()
 
     def to_internal_value(self, value: Union[int, float]) -> timedelta:
+        """
+        Converts a number of seconds into a ``timedelta``.
+        """
         return timedelta(seconds=value)
 
 
@@ -371,11 +386,11 @@ class _DictField(Generic[T], _BasicField[T]):
     valid_types = dict
 
 
-class _ListField(Generic[T], Field[List[RecordId], List[T]]):
+class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM]]):
     """
     Generic type for a field that stores a list of values. Can be used
     to refer to a lookup field that might return more than one value.
-    Not for use via API; should be subclassed by concrete field types (below).
+    Not for direct use; should be subclassed by concrete field types (below).
     """
 
     valid_types = list
@@ -388,18 +403,18 @@ class _ListField(Generic[T], Field[List[RecordId], List[T]]):
         ...
 
     @overload
-    def __get__(self, instance: "Model", owner: Type[Any]) -> List[T]:
+    def __get__(self, instance: "Model", owner: Type[Any]) -> List[T_ORM]:
         ...
 
     def __get__(
         self, instance: Optional["Model"], owner: Type[Any]
-    ) -> Union[SelfType, List[T]]:
+    ) -> Union[SelfType, List[T_ORM]]:
         if not instance:
             return self
         return self._get_list_value(instance)
 
-    def _get_list_value(self, instance: "Model") -> List[T]:
-        value = cast(List[T], instance._fields.get(self.field_name))
+    def _get_list_value(self, instance: "Model") -> List[T_ORM]:
+        value = cast(List[T_ORM], instance._fields.get(self.field_name))
         # If Airtable returns no value, substitute an empty list.
         if value is None:
             value = []
@@ -410,13 +425,13 @@ class _ListField(Generic[T], Field[List[RecordId], List[T]]):
                 instance._fields[self.field_name] = value
         return value
 
-    def to_internal_value(self, value: Optional[List[T]]) -> List[T]:
+    def to_internal_value(self, value: Optional[List[T_ORM]]) -> List[T_ORM]:
         if value is None:
             value = []
         return value
 
 
-class _ValidatingListField(Generic[T], _ListField[T]):
+class _ValidatingListField(Generic[T], _ListField[T, T]):
     contains_type: Type[T]
 
     def valid_or_raise(self, value: Any) -> None:
@@ -434,7 +449,7 @@ class _LinkFieldOptions(Enum):
 LinkSelf = _LinkFieldOptions.LinkSelf
 
 
-class LinkField(_ListField[T_Linked]):
+class LinkField(_ListField[RecordId, T_Linked]):
     """
     Represents a MultipleRecordLinks field. Returns and accepts lists of Models.
 
@@ -491,7 +506,10 @@ class LinkField(_ListField[T_Linked]):
 
     @property
     def linked_model(self) -> Type[T_Linked]:
-        # This avoids resolving the model name into a class until we need it
+        """
+        Resolves a :class:`~pyairtable.orm.Model` class based on
+        the ``model=`` constructor parameter to this field instance.
+        """
         if isinstance(self._linked_model, str):
             modpath, _, clsname = self._linked_model.rpartition(".")
             if not modpath:
@@ -502,7 +520,7 @@ class LinkField(_ListField[T_Linked]):
             cls = getattr(mod, clsname)
             self._linked_model = cast(Type[T_Linked], cls)
 
-        if self._linked_model is _LinkFieldOptions.LinkSelf:
+        elif self._linked_model is _LinkFieldOptions.LinkSelf:
             if self._model is None:
                 raise RuntimeError(f"{self._description} not created on a Model")
             self._linked_model = cast(Type[T_Linked], self._model)
@@ -517,11 +535,13 @@ class LinkField(_ListField[T_Linked]):
             ("lazy", self._lazy),
         ]
 
-    # Unlike most other field classes, LinkField does not store its
-    # internal representation (T_ORM) in instance._fields at first;
-    # we defer object creation to the first time they're requested,
-    # so we can avoid infinite recursion during to_internal_value().
     def _get_list_value(self, instance: "Model") -> List[T_Linked]:
+        """
+        Unlike most other field classes, LinkField does not store its internal
+        representation (T_ORM) in instance._fields after Model.from_record().
+        Instead, we defer creating objects until they're requested for the first
+        time, so we can avoid infinite recursion during to_internal_value().
+        """
         if not (records := super()._get_list_value(instance)):
             return records
         # If there are any values which are IDs rather than instances,
@@ -545,10 +565,13 @@ class LinkField(_ListField[T_Linked]):
         return records
 
     def to_record_value(self, value: Union[List[str], List[T_Linked]]) -> List[str]:
+        """
+        Returns the list of record IDs which should be persisted to the API.
+        """
         if not value:
             return []
         # If the _fields value contains str, it means we loaded it from the API
-        # but we never actually accessed the value (see _get_value below).
+        # but we never actually accessed the value (see _get_list_value).
         # When persisting this model back to the API, we can just write those IDs.
         if all(isinstance(v, str) for v in value):
             return cast(List[str], value)
@@ -577,6 +600,15 @@ class LinkField(_ListField[T_Linked]):
 # get some extra functionality for free in the future.
 
 
+class AttachmentsField(_ValidatingListField[AttachmentDict]):
+    """
+    Accepts a list of dicts in the format detailed in
+    `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`_.
+    """
+
+    contains_type = cast(Type[AttachmentDict], dict)
+
+
 class AutoNumberField(IntegerField):
     """
     Equivalent to :class:`IntegerField(readonly=True) <IntegerField>`.
@@ -689,7 +721,7 @@ class LastModifiedTimeField(DatetimeField):
     readonly = True
 
 
-class LookupField(Generic[T], _ListField[T]):
+class LookupField(Generic[T], _ListField[T, T]):
     """
     Generic field class for a lookup, which returns a list of values.
 
@@ -711,15 +743,6 @@ class LookupField(Generic[T], _ListField[T]):
     readonly = True
 
 
-class MultipleAttachmentsField(_ValidatingListField[AttachmentDict]):
-    """
-    Accepts a list of dicts in the format detailed in
-    `Attachments <https://airtable.com/developers/web/api/field-model#multipleattachment>`_.
-    """
-
-    contains_type = cast(Type[AttachmentDict], dict)
-
-
 class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]):
     """
     Accepts a list of dicts in the format detailed in
@@ -827,7 +850,7 @@ FIELD_TYPES_TO_CLASSES = {
     "lastModifiedTime": LastModifiedTimeField,
     "lookup": LookupField,
     "multilineText": TextField,
-    "multipleAttachments": MultipleAttachmentsField,
+    "multipleAttachments": AttachmentsField,
     "multipleCollaborators": MultipleCollaboratorsField,
     "multipleRecordLinks": LinkField,
     "multipleSelects": MultipleSelectField,
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index fdce40e..30d25bc 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -8,10 +8,10 @@ from pyairtable.api.base import Base
 from pyairtable.api.table import Table
 from pyairtable.api.types import (
     FieldName,
-    Fields,
     RecordDict,
     RecordId,
     UpdateRecordDict,
+    WritableFields,
 )
 from pyairtable.formulas import OR, STR_VALUE
 from pyairtable.orm.fields import AnyField, Field
@@ -73,7 +73,7 @@ class Model:
     @classmethod
     def _field_name_descriptor_map(cls) -> Dict[FieldName, AnyField]:
         """
-        Returns a dictionary that maps Fields 'Names' to descriptor fields
+        Returns a dictionary that maps field names to descriptor instances.
 
         >>> class Test(Model):
         ...     first_name = TextField("First Name")
@@ -90,7 +90,7 @@ class Model:
     @classmethod
     def _field_name_attribute_map(cls) -> Dict[FieldName, str]:
         """
-        Returns a dictionary that maps Fields 'Names' to the model attribute name:
+        Returns a dictionary that maps field names to attribute names.
 
         >>> class Test(Model):
         ...     first_name = TextField("First Name")
@@ -173,7 +173,7 @@ class Model:
         Saves or updates a model.
         If instance has no 'id', it will be created, otherwise updated.
 
-        Returns `True` if was created and `False` if it was updated
+        Returns ``True`` if was created and `False` if it was updated
         """
         if self._deleted:
             raise RuntimeError(f"{self.id} was deleted")
@@ -204,8 +204,8 @@ class Model:
     @classmethod
     def all(cls, **kwargs: Any) -> List[SelfType]:
         """
-        Returns all records for this model. For the full list of
-        keyword arguments, see :meth:`~pyairtable.Api.all`
+        Returns all records for this model. For all supported
+        keyword arguments, see :meth:`Table.all <pyairtable.Table.all>`.
         """
         table = cls.get_table()
         return [cls.from_record(record) for record in table.all(**kwargs)]
@@ -213,8 +213,8 @@ class Model:
     @classmethod
     def first(cls, **kwargs: Any) -> Optional[SelfType]:
         """
-        Returns the first record for this model. For the full list of
-        keyword arguments, see :meth:`~pyairtable.Api.all`
+        Returns the first record for this model. For all supported
+        keyword arguments, see :meth:`Table.first <pyairtable.Table.first>`.
         """
         table = cls.get_table()
         if record := table.first(**kwargs):
@@ -225,8 +225,8 @@ class Model:
         """
         Returns a dictionary object as an Airtable record.
         This method converts internal field values into values expected by Airtable.
-        e.g. a ``datetime`` value from :class:``DateTimeField`` is converted into an
-        ISO 8601 string
+        For example, a ``datetime`` value from :class:`~pyairtable.orm.fields.DatetimeField`
+        is converted into an ISO 8601 string.
 
         Args:
             only_writable: If ``True``, the result will exclude any
@@ -243,10 +243,7 @@ class Model:
     @classmethod
     def from_record(cls, record: RecordDict) -> SelfType:
         """
-        Create instance from record dict
-
-        Args:
-            record: The dict returned from the Airtable API.
+        Create an instance from a record dict.
         """
         name_field_map = cls._field_name_descriptor_map()
         # Convert Column Names into model field names
@@ -271,7 +268,7 @@ class Model:
         fetch: bool = True,
     ) -> SelfType:
         """
-        Create an instance from a `record_id`
+        Create an instance from a record ID.
 
         Args:
             record_id: |arg_record_id|
@@ -285,7 +282,9 @@ class Model:
         return instance
 
     def fetch(self) -> None:
-        """Fetches field and resets instance field values from the Airtable record"""
+        """
+        Fetches field values from the API and resets instance field values.
+        """
         if not self.id:
             raise ValueError("cannot be fetched because instance does not have an id")
 
@@ -336,7 +335,7 @@ class Model:
 
         create_models = [model for model in models if not model.id]
         update_models = [model for model in models if model.id]
-        create_records: List[Fields] = [
+        create_records: List[WritableFields] = [
             record["fields"]
             for model in create_models
             if (record := model.to_record(only_writable=True))
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index e23b1c4..1ca55bd 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -19,22 +19,20 @@ def datetime_to_iso_str(value: datetime) -> str:
 
 def datetime_from_iso_str(value: str) -> datetime:
     """
-    Converts ISO 8601 datetime string into a ``datetime`` object.
-    Expected format is "2014-09-05T07:00:00.000Z"
+    Convert an ISO 8601 datetime string into a ``datetime`` object.
 
     Args:
-        value: datetime string e.g. "2014-09-05T07:00:00.000Z"
+        value: datetime string, e.g. "2014-09-05T07:00:00.000Z"
     """
     return datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%fZ")
 
 
 def date_to_iso_str(value: Union[date, datetime]) -> str:
     """
-    Converts ``date`` or ``datetime`` object into Airtable compatible ISO 8601 string
-    e.g. "2014-09-05"
+    Converts a ``date`` or ``datetime`` into an Airtable-compatible ISO 8601 string
 
     Args:
-        value: date or datetime object
+        value: date or datetime object, e.g. "2014-09-05"
     """
     return value.strftime("%Y-%m-%d")
 
@@ -42,10 +40,9 @@ def date_to_iso_str(value: Union[date, datetime]) -> str:
 def date_from_iso_str(value: str) -> date:
     """
     Converts ISO 8601 date string into a ``date`` object.
-    Expected format is  "2014-09-05"
 
     Args:
-        value: date string e.g. "2014-09-05"
+        value: date string, e.g. "2014-09-05"
     """
     return datetime.strptime(value, "%Y-%m-%d").date()
 
@@ -59,7 +56,10 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
     All other attachment object properties will be generated server-side soon afterward.
 
     Note:
-        Attachment field values muest be **an array of objects**.
+        Attachment field values **must** be an array of
+        :class:`~pyairtable.api.types.AttachmentDict` or
+        :class:`~pyairtable.api.types.CreateAttachmentDict`;
+        it is not valid to pass a single item to the API.
 
     Usage:
         >>> table = Table(...)
diff --git a/setup.cfg b/setup.cfg
index 799f1f8..d6b4f28 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,7 +29,7 @@ classifiers =
 [options]
 packages = find:
 install_requires =
-    pydantic
+    pydantic ~= 1.10
     requests >= 2.22.0
     typing_extensions
     urllib3 >= 1.26


Test patch:
diff --git a/pyairtable/testing.py b/pyairtable/testing.py
index 248cde4..3cda433 100644
--- a/pyairtable/testing.py
+++ b/pyairtable/testing.py
@@ -6,13 +6,7 @@ import random
 import string
 from typing import Any, Optional
 
-from pyairtable.api.types import (
-    AttachmentDict,
-    CollaboratorDict,
-    Fields,
-    FieldValue,
-    RecordDict,
-)
+from pyairtable.api.types import AttachmentDict, CollaboratorDict, Fields, RecordDict
 
 
 def fake_id(type: str = "rec", value: Any = None) -> str:
@@ -50,7 +44,7 @@ def fake_meta(
 def fake_record(
     fields: Optional[Fields] = None,
     id: Optional[str] = None,
-    **other_fields: FieldValue,
+    **other_fields: Any,
 ) -> RecordDict:
     """
     Returns a fake record dict with the given field values.
diff --git a/tests/integration/test_integration_metadata.py b/tests/integration/test_integration_metadata.py
index 8babc31..d1f25d6 100644
--- a/tests/integration/test_integration_metadata.py
+++ b/tests/integration/test_integration_metadata.py
@@ -11,13 +11,21 @@ def test_get_api_bases(base: Base, base_name: str):
     assert base_name in [b["name"] for b in rv["bases"]]
 
 
-@pytest.mark.skip("metadata api returning 404 for base schema")
 def test_get_base_schema(base: Base):
     rv = get_base_schema(base)
-    assert len(rv["tables"]) == 3
+    assert sorted(table["name"] for table in rv["tables"]) == [
+        "Address",
+        "Contact",
+        "EVERYTHING",
+        "TEST_TABLE",
+    ]
 
 
-@pytest.mark.skip("metadata api returning 404 for base schema")
 def test_get_table_schema(table: Table):
     rv = get_table_schema(table)
     assert rv and rv["name"] == table.name
+
+
+def test_get_table_schema__invalid_table(table, monkeypatch):
+    monkeypatch.setattr(table, "name", "DoesNotExist")
+    assert get_table_schema(table) is None
diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py
index ac82855..1d064ee 100644
--- a/tests/integration/test_integration_orm.py
+++ b/tests/integration/test_integration_orm.py
@@ -41,7 +41,7 @@ class _Everything(Model):
     assignee = f.CollaboratorField("Assignee")
     watchers = f.MultipleCollaboratorsField("Watchers")
     status = f.SelectField("Status")
-    attachments = f.MultipleAttachmentsField("Attachments")
+    attachments = f.AttachmentsField("Attachments")
     done = f.CheckboxField("Done")
     tags = f.MultipleSelectField("Tags")
     date = f.DateField("Date")
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 5c247e9..7d3763a 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -72,7 +72,7 @@ def test_repr(instance, expected):
         (f.Field, None),
         (f.CheckboxField, False),
         (f.LookupField, []),
-        (f.MultipleAttachmentsField, []),
+        (f.AttachmentsField, []),
         (f.MultipleCollaboratorsField, []),
         (f.MultipleSelectField, []),
     ],
@@ -125,7 +125,7 @@ TYPE_VALIDATION_TEST_VALUES = {
         (f.CollaboratorField, dict),
         (f.SelectField, str),
         (f.EmailField, str),
-        (f.MultipleAttachmentsField, list),
+        (f.AttachmentsField, list),
         (f.MultipleCollaboratorsField, list),
         (f.CurrencyField, (int, float)),
     ],
@@ -258,7 +258,7 @@ def test_readonly_fields(test_case):
         (f.CurrencyField, 1.05),
         (f.CheckboxField, True),
         (f.CollaboratorField, {"id": "usrFakeUserId", "email": "x@y.com"}),
-        (f.MultipleAttachmentsField, [fake_attachment(), fake_attachment()]),
+        (f.AttachmentsField, [fake_attachment(), fake_attachment()]),
         (f.MultipleSelectField, ["any", "values"]),
         (f.MultipleCollaboratorsField, [fake_user(), fake_user()]),
         (f.BarcodeField, {"type": "upce", "text": "084114125538"}),
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 8e7c892..3b55217 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -1,7 +1,8 @@
 """
 Tests that pyairtable.api functions/methods return appropriately typed responses.
 """
-from typing import TYPE_CHECKING, Iterator, List, Optional
+import datetime
+from typing import TYPE_CHECKING, Iterator, List, Optional, Union
 
 from typing_extensions import assert_type
 
@@ -52,24 +53,83 @@ if TYPE_CHECKING:
     table.update(record_id, {"Field Name": None})
     table.update(record_id, {"Field Name": {"id": "usrXXX"}})
     table.update(record_id, {"Field Name": {"email": "alice@example.com"}})
+    table.update(record_id, {"Field Name": ["rec1", "rec2", "rec3"]})
 
     # Test type annotations for the ORM
     class Actor(orm.Model):
         name = orm.fields.TextField("Name")
         logins = orm.fields.MultipleCollaboratorsField("Logins")
 
+    assert_type(Actor().name, Optional[str])
+    assert_type(Actor().logins, List[T.CollaboratorDict])
+
     class Movie(orm.Model):
         name = orm.fields.TextField("Name")
         rating = orm.fields.RatingField("Star Rating")
         prequels = orm.fields.LinkField["Movie"]("Prequels", "path.to.Movie")
         actors = orm.fields.LinkField("Actors", Actor)
 
-    assert_type(Actor().name, Optional[str])
-    assert_type(Actor().logins, List[T.CollaboratorDict])
-
     movie = Movie()
     assert_type(movie.name, Optional[str])
     assert_type(movie.rating, Optional[int])
     assert_type(movie.actors, List[Actor])
     assert_type(movie.prequels, List[Movie])
     assert_type(movie.actors[0].name, Optional[str])
+
+    class EveryField(orm.Model):
+        attachments = orm.fields.AttachmentsField("Attachments")
+        autonumber = orm.fields.AutoNumberField("Autonumber")
+        barcode = orm.fields.BarcodeField("Barcode")
+        button = orm.fields.ButtonField("Open URL")
+        checkbox = orm.fields.CheckboxField("Done")
+        collaborator = orm.fields.CollaboratorField("Assignee")
+        count = orm.fields.CountField("Count")
+        created_by = orm.fields.CreatedByField("Created By")
+        created = orm.fields.CreatedTimeField("Created")
+        currency = orm.fields.CurrencyField("Dollars")
+        date = orm.fields.DateField("Date")
+        datetime = orm.fields.DatetimeField("DateTime")
+        duration = orm.fields.DurationField("Duration (h:mm)")
+        email = orm.fields.EmailField("Email")
+        float = orm.fields.FloatField("Decimal 1")
+        integer = orm.fields.IntegerField("Integer")
+        last_modified_by = orm.fields.LastModifiedByField("Last Modified By")
+        last_modified = orm.fields.LastModifiedTimeField("Last Modified")
+        multi_user = orm.fields.MultipleCollaboratorsField("Watchers")
+        multi_select = orm.fields.MultipleSelectField("Tags")
+        number = orm.fields.NumberField("Number")
+        percent = orm.fields.PercentField("Percent")
+        phone = orm.fields.PhoneNumberField("Phone")
+        rating = orm.fields.RatingField("Stars")
+        rich_text = orm.fields.RichTextField("Notes")
+        select = orm.fields.SelectField("Status")
+        url = orm.fields.UrlField("URL")
+
+    record = EveryField()
+    assert_type(record.attachments, List[T.AttachmentDict])
+    assert_type(record.autonumber, Optional[int])
+    assert_type(record.barcode, Optional[T.BarcodeDict])
+    assert_type(record.button, Optional[T.ButtonDict])
+    assert_type(record.checkbox, Optional[bool])
+    assert_type(record.collaborator, Optional[T.CollaboratorDict])
+    assert_type(record.count, Optional[int])
+    assert_type(record.created_by, Optional[T.CollaboratorDict])
+    assert_type(record.created, Optional[datetime.datetime])
+    assert_type(record.currency, Optional[Union[int, float]])
+    assert_type(record.date, Optional[datetime.date])
+    assert_type(record.datetime, Optional[datetime.datetime])
+    assert_type(record.duration, Optional[datetime.timedelta])
+    assert_type(record.email, Optional[str])
+    assert_type(record.float, Optional[float])
+    assert_type(record.integer, Optional[int])
+    assert_type(record.last_modified_by, Optional[T.CollaboratorDict])
+    assert_type(record.last_modified, Optional[datetime.datetime])
+    assert_type(record.multi_user, List[T.CollaboratorDict])
+    assert_type(record.multi_select, List[str])
+    assert_type(record.number, Optional[Union[int, float]])
+    assert_type(record.percent, Optional[Union[int, float]])
+    assert_type(record.phone, Optional[str])
+    assert_type(record.rating, Optional[int])
+    assert_type(record.rich_text, Optional[str])
+    assert_type(record.select, Optional[str])
+    assert_type(record.url, Optional[str])