Repository: gtalarico/pyairtable
Pull request #389 (2024-09-08 20:49:18)

Support new "Upload attachment" endpoint
This branch adds `Table.upload_attachment()` and `Model.attachments_field.upload()` to support the recently introduced [Upload attachment](https://airtable.com/developers/web/api/upload-attachment) endpoint. Along the way it required a bit of a rethink of how we handle lists in the ORM, especially after #381 introduced a custom list subclass (which makes the type system for the ORM a bit more complex).


Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index c0e4041..8e9f285 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -43,6 +43,9 @@ Changelog
 * Changed the return type of :meth:`Model.save <pyairtable.orm.Model.save>`
   from ``bool`` to :class:`~pyairtable.orm.SaveResult`.
   - `PR #387 <https://github.com/gtalarico/pyairtable/pull/387>`_
+* Added support for `Upload attachment <https://airtable.com/developers/web/api/upload-attachment>`_
+  via :meth:`Table.upload_attachment <pyairtable.Table.upload_attachment>`
+  or :meth:`AttachmentsList.upload <pyairtable.orm.lists.AttachmentsList.upload>`.
 
 2.3.3 (2024-03-22)
 ------------------------
diff --git a/docs/source/migrations.rst b/docs/source/migrations.rst
index fa6e9d9..79444bd 100644
--- a/docs/source/migrations.rst
+++ b/docs/source/migrations.rst
@@ -65,30 +65,47 @@ The full list of breaking changes is below:
 Changes to the ORM in 3.0
 ---------------------------------------------
 
-:data:`Model.created_time <pyairtable.orm.Model.created_time>` is now a ``datetime`` (or ``None``)
-instead of ``str``. This change also applies to all timestamp fields used in :ref:`API: pyairtable.models`.
-
-:meth:`Model.save <pyairtable.orm.Model.save>` now only saves changed fields to the API, which
-means it will sometimes not perform any network traffic (though this behavior can be overridden).
-It also now returns an instance of :class:`~pyairtable.orm.SaveResult` instead of ``bool``.
-
-The 3.0 release has changed the API for retrieving ORM model configuration:
+* :data:`Model.created_time <pyairtable.orm.Model.created_time>` is now a ``datetime`` (or ``None``)
+  instead of ``str``. This change also applies to all timestamp fields used in :ref:`API: pyairtable.models`.
+
+* :meth:`Model.save <pyairtable.orm.Model.save>` now only saves changed fields to the API, which
+  means it will sometimes not perform any network traffic (though this behavior can be overridden).
+  It also now returns an instance of :class:`~pyairtable.orm.SaveResult` instead of ``bool``.
+
+* Fields which contain lists of values now return instances of ``ChangeTrackingList``, which
+  is still a subclass of ``list``. This should not affect most uses, but it does mean that
+  some code which relies on exact type checking may need to be updated:
+
+    >>> isinstance(Foo().atts, list)
+    True
+    >>> type(Foo().atts) is list
+    False
+    >>> type(Foo().atts)
+    <class 'pyairtable.orm.lists.ChangeTrackingList'>
+
+* The 3.0 release has changed the API for retrieving ORM model configuration:
+
+  .. list-table::
+      :header-rows: 1
+
+      * - Method in 2.x
+        - Method in 3.0
+      * - ``Model.get_api()``
+        - ``Model.meta.api``
+      * - ``Model.get_base()``
+        - ``Model.meta.base``
+      * - ``Model.get_table()``
+        - ``Model.meta.table``
+      * - ``Model._get_meta(name)``
+        - ``Model.meta.get(name)``
+
+Breaking type changes
+---------------------------------------------
 
-.. list-table::
-    :header-rows: 1
+* ``pyairtable.api.types.CreateAttachmentDict`` is now a ``Union`` instead of a ``TypedDict``,
+  which may change some type checking behavior in code that uses it.
 
-    * - Method in 2.x
-      - Method in 3.0
-    * - ``Model.get_api()``
-      - ``Model.meta.api``
-    * - ``Model.get_base()``
-      - ``Model.meta.base``
-    * - ``Model.get_table()``
-      - ``Model.meta.table``
-    * - ``Model._get_meta(name)``
-      - ``Model.meta.get(name)``
-
-Miscellaneous name changes
+Breaking name changes
 ---------------------------------------------
 
     * - | ``pyairtable.api.enterprise.ClaimUsersResponse``
diff --git a/docs/source/orm.rst b/docs/source/orm.rst
index 40473cc..63ac1b3 100644
--- a/docs/source/orm.rst
+++ b/docs/source/orm.rst
@@ -583,6 +583,37 @@ comments on a particular record, just like their :class:`~pyairtable.Table` equi
     >>> comment.delete()
 
 
+Attachments in the ORM
+----------------------
+
+When retrieving attachments from the API, pyAirtable will return a list of
+:class:`~pyairtable.api.types.AttachmentDict`.
+
+    >>> model = YourModel.from_id("recMNxslc6jG0XedV")
+    >>> model.attachments
+    [
+        {
+          'id': 'attMNxslc6jG0XedV',
+          'url': 'https://dl.airtable.com/...',
+          'filename': 'example.jpg',
+          'size': 12345,
+          'type': 'image/jpeg'
+        },
+        ...
+    ]
+
+You can append your own values to this list, and as long as they have
+either a ``"id"`` or ``"url"`` key, they will be saved back to the API.
+
+    >>> model.attachments.append({"url": "https://example.com/example.jpg"})
+    >>> model.save()
+
+You can also use :meth:`~pyairtable.orm.lists.AttachmentsList.upload` to
+directly upload content to Airtable:
+
+.. automethod:: pyairtable.orm.lists.AttachmentsList.upload
+
+
 ORM Limitations
 ------------------
 
diff --git a/pyairtable/api/table.py b/pyairtable/api/table.py
index a9d4294..687958c 100644
--- a/pyairtable/api/table.py
+++ b/pyairtable/api/table.py
@@ -1,6 +1,10 @@
+import base64
+import mimetypes
+import os
 import posixpath
 import urllib.parse
 import warnings
+from pathlib import Path
 from typing import Any, Dict, Iterable, Iterator, List, Optional, Union, overload
 
 import pyairtable.models
@@ -11,6 +15,7 @@ from pyairtable.api.types import (
     RecordDict,
     RecordId,
     UpdateRecordDict,
+    UploadAttachmentResultDict,
     UpsertResultDict,
     WritableFields,
     assert_typed_dict,
@@ -657,13 +662,23 @@ class Table:
     def create_field(
         self,
         name: str,
-        type: str,
+        field_type: str,
         description: Optional[str] = None,
         options: Optional[Dict[str, Any]] = None,
     ) -> FieldSchema:
         """
         Create a field on the table.
 
+        Usage:
+            >>> table.create_field("Attachments", "multipleAttachment")
+            FieldSchema(
+                id='fldslc6jG0XedVMNx',
+                name='Attachments',
+                type='multipleAttachment',
+                description=None,
+                options=MultipleAttachmentsFieldOptions(is_reversed=False)
+            )
+
         Args:
             name: The unique name of the field.
             field_type: One of the `Airtable field types <https://airtable.com/developers/web/api/model/field-type>`__.
@@ -671,7 +686,7 @@ class Table:
             options: Only available for some field types. For more information, read about the
                 `Airtable field model <https://airtable.com/developers/web/api/field-model>`__.
         """
-        request: Dict[str, Any] = {"name": name, "type": type}
+        request: Dict[str, Any] = {"name": name, "type": field_type}
         if description:
             request["description"] = description
         if options:
@@ -691,6 +706,73 @@ class Table:
             self._schema.fields.append(field_schema)
         return field_schema
 
+    def upload_attachment(
+        self,
+        record_id: RecordId,
+        field: str,
+        filename: Union[str, Path],
+        content: Optional[Union[str, bytes]] = None,
+        content_type: Optional[str] = None,
+    ) -> UploadAttachmentResultDict:
+        """
+        Upload an attachment to the Airtable API, either by supplying the path to the file
+        or by providing the content directly as a variable.
+
+        See `Upload attachment <https://airtable.com/developers/web/api/upload-attachment>`__.
+
+        Usage:
+            >>> table.upload_attachment("recAdw9EjV90xbZ", "Attachments", "/tmp/example.jpg")
+            {
+                'id': 'recAdw9EjV90xbZ',
+                'createdTime': '2023-05-22T21:24:15.333134Z',
+                'fields': {
+                    'Attachments': [
+                        {
+                            'id': 'attW8eG2x0ew1Af',
+                            'url': 'https://content.airtable.com/...',
+                            'filename': 'example.jpg'
+                        }
+                    ]
+                }
+            }
+
+        Args:
+            record_id: |arg_record_id|
+            field: The ID or name of the ``multipleAttachments`` type field.
+            filename: The path to the file to upload. If ``content`` is provided, this
+                argument is still used to tell Airtable what name to give the file.
+            content: The content of the file as a string or bytes object. If no value
+                is provided, pyAirtable will attempt to read the contents of ``filename``.
+            content_type: The MIME type of the file. If not provided, the library will attempt to
+                guess the content type based on ``filename``.
+
+        Returns:
+            A full list of attachments in the given field, including the new attachment.
+        """
+        if content is None:
+            with open(filename, "rb") as fp:
+                content = fp.read()
+            return self.upload_attachment(
+                record_id, field, filename, content, content_type
+            )
+
+        filename = os.path.basename(filename)
+        if content_type is None:
+            if not (content_type := mimetypes.guess_type(filename)[0]):
+                warnings.warn(f"Could not guess content-type for {filename!r}")
+                content_type = "application/octet-stream"
+
+        # TODO: figure out how to handle the atypical subdomain in a more graceful fashion
+        url = f"https://content.airtable.com/v0/{self.base.id}/{record_id}/{field}/uploadAttachment"
+        content = content.encode() if isinstance(content, str) else content
+        payload = {
+            "contentType": content_type,
+            "filename": filename,
+            "file": base64.encodebytes(content).decode("utf8"),  # API needs Unicode
+        }
+        response = self.api.post(url, json=payload)
+        return assert_typed_dict(UploadAttachmentResultDict, response)
+
 
 # These are at the bottom of the module to avoid circular imports
 import pyairtable.api.api  # noqa
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index 5ab90ca..2753a60 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -76,7 +76,20 @@ class AttachmentDict(TypedDict, total=False):
     thumbnails: Dict[str, Dict[str, Union[str, int]]]
 
 
-class CreateAttachmentDict(TypedDict, total=False):
+class CreateAttachmentById(TypedDict):
+    """
+    A ``dict`` representing a new attachment to be written to the Airtable API.
+
+    >>> new_attachment = {"id": "attW8eG2x0ew1Af"}
+    >>> existing = record["fields"].setdefault("Attachments", [])
+    >>> existing.append(new_attachment)
+    >>> table.update(existing["id"], existing["fields"])
+    """
+
+    id: str
+
+
+class CreateAttachmentByUrl(TypedDict, total=False):
     """
     A ``dict`` representing a new attachment to be written to the Airtable API.
 
@@ -93,6 +106,9 @@ class CreateAttachmentDict(TypedDict, total=False):
     filename: str
 
 
+CreateAttachmentDict: TypeAlias = Union[CreateAttachmentById, CreateAttachmentByUrl]
+
+
 class BarcodeDict(TypedDict, total=False):
     """
     A ``dict`` representing the value stored in a Barcode field.
@@ -350,6 +366,33 @@ class UserAndScopesDict(TypedDict, total=False):
     scopes: List[str]
 
 
+class UploadAttachmentResultDict(TypedDict):
+    """
+    A ``dict`` representing the payload returned by
+    `Upload attachment <https://airtable.com/developers/web/api/upload-attachment>`__.
+
+    Usage:
+        >>> table.upload_attachment("recAdw9EjV90xbZ", "Attachments", "/tmp/example.jpg")
+        {
+            'id': 'recAdw9EjV90xbZ',
+            'createdTime': '2023-05-22T21:24:15.333134Z',
+            'fields': {
+                'Attachments': [
+                    {
+                        'id': 'attW8eG2x0ew1Af',
+                        'url': 'https://content.airtable.com/...',
+                        'filename': 'example.jpg'
+                    }
+                ]
+            }
+        }
+    """
+
+    id: RecordId
+    createdTime: str
+    fields: Dict[str, List[AttachmentDict]]
+
+
 @lru_cache
 def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
     """
@@ -397,6 +440,18 @@ def assert_typed_dict(cls: Type[T], obj: Any) -> T:
     """
     if not isinstance(obj, dict):
         raise TypeError(f"expected dict, got {type(obj)}")
+
+    # special case for handling a Union
+    if getattr(cls, "__origin__", None) is Union:
+        typeddict_classes = list(getattr(cls, "__args__", []))
+        while typeddict_cls := typeddict_classes.pop():
+            try:
+                return cast(T, assert_typed_dict(typeddict_cls, obj))
+            except pydantic.ValidationError:
+                # raise the last exception if we've tried everything
+                if not typeddict_classes:
+                    raise
+
     # mypy complains cls isn't Hashable, but it is; see https://github.com/python/mypy/issues/2412
     model = _create_model_from_typeddict(cls)  # type: ignore
     model(**obj)
diff --git a/pyairtable/exceptions.py b/pyairtable/exceptions.py
index 74360c7..69afe1d 100644
--- a/pyairtable/exceptions.py
+++ b/pyairtable/exceptions.py
@@ -26,3 +26,15 @@ class MultipleValuesError(PyAirtableError, ValueError):
     """
     SingleLinkField received more than one value from either Airtable or calling code.
     """
+
+
+class ReadonlyFieldError(PyAirtableError, ValueError):
+    """
+    Attempted to set a value on a readonly field.
+    """
+
+
+class UnsavedRecordError(PyAirtableError, ValueError):
+    """
+    Attempted to perform an unsupported operation on an unsaved record.
+    """
diff --git a/pyairtable/orm/changes.py b/pyairtable/orm/changes.py
deleted file mode 100644
index 021b8d2..0000000
--- a/pyairtable/orm/changes.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from typing import Callable, Iterable, List, SupportsIndex, Union
-
-from typing_extensions import TypeVar
-
-T = TypeVar("T")
-
-
-class ChangeNotifyingList(List[T]):
-    """
-    A list that calls a callback any time it is changed. This allows us to know
-    if any mutations happened to the lists returned from linked record fields.
-    """
-
-    def __init__(self, *args: Iterable[T], on_change: Callable[[], None]) -> None:
-        super().__init__(*args)
-        self._on_change = on_change
-
-    def __setitem__(self, index: SupportsIndex, value: T) -> None:  # type: ignore[override]
-        self._on_change()
-        return super().__setitem__(index, value)
-
-    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
-        self._on_change()
-        return super().__delitem__(key)
-
-    def append(self, object: T) -> None:
-        self._on_change()
-        return super().append(object)
-
-    def insert(self, index: SupportsIndex, object: T) -> None:
-        self._on_change()
-        return super().insert(index, object)
-
-    def remove(self, value: T) -> None:
-        self._on_change()
-        return super().remove(value)
-
-    def clear(self) -> None:
-        self._on_change()
-        return super().clear()
-
-    def extend(self, iterable: Iterable[T]) -> None:
-        self._on_change()
-        return super().extend(iterable)
-
-    def pop(self, index: SupportsIndex = -1) -> T:
-        self._on_change()
-        return super().pop(index)
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 3eeaae6..27e00d9 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -29,7 +29,6 @@ import abc
 import importlib
 from datetime import date, datetime, timedelta
 from enum import Enum
-from functools import partial
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -58,10 +57,16 @@ from pyairtable.api.types import (
     BarcodeDict,
     ButtonDict,
     CollaboratorDict,
+    CollaboratorEmailDict,
+    CreateAttachmentDict,
     RecordId,
 )
-from pyairtable.exceptions import MissingValueError, MultipleValuesError
-from pyairtable.orm.changes import ChangeNotifyingList
+from pyairtable.exceptions import (
+    MissingValueError,
+    MultipleValuesError,
+    UnsavedRecordError,
+)
+from pyairtable.orm.lists import AttachmentsList, ChangeTrackingList
 
 if TYPE_CHECKING:
     from pyairtable.orm import Model  # noqa
@@ -71,7 +76,8 @@ _ClassInfo: TypeAlias = Union[type, Tuple["_ClassInfo", ...]]
 T = TypeVar("T")
 T_Linked = TypeVar("T_Linked", bound="Model")  # used by LinkField
 T_API = TypeVar("T_API")  # type used to exchange values w/ Airtable API
-T_ORM = TypeVar("T_ORM")  # type used to store values internally
+T_ORM = TypeVar("T_ORM")  # type used to represent values internally
+T_ORM_List = TypeVar("T_ORM_List")  # type used for lists of internal values
 T_Missing = TypeVar("T_Missing")  # type returned when Airtable has no value
 
 
@@ -466,60 +472,88 @@ class _DictField(Generic[T], _BasicField[T]):
     valid_types = dict
 
 
-class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM], List[T_ORM]]):
+class _ListFieldBase(
+    Generic[T_API, T_ORM, T_ORM_List],
+    Field[List[T_API], List[T_ORM], T_ORM_List],
+):
     """
-    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.
+    Generic type for a field that stores a list of values.
     Not for direct use; should be subclassed by concrete field types (below).
+
+    Generic type parameters:
+        * ``T_API``: The type of value returned by the Airtable API.
+        * ``T_ORM``: The type of value stored internally.
+        * ``T_ORM_List``: The type of list object that will be returned.
     """
 
     valid_types = list
+    list_class: Type[T_ORM_List]
+    contains_type: Optional[Type[T_ORM]]
 
     # List fields will always return a list, never ``None``, so we
     # have to overload the type annotations for __get__
 
+    def __init_subclass__(cls, **kwargs: Any) -> None:
+        cls.contains_type = kwargs.pop("contains_type", None)
+        cls.list_class = kwargs.pop("list_class", ChangeTrackingList)
+
+        if cls.contains_type and not isinstance(cls.contains_type, type):
+            raise TypeError(f"contains_type= expected a type, got {cls.contains_type}")
+        if not isinstance(cls.list_class, type):
+            raise TypeError(f"list_class= expected a type, got {cls.list_class}")
+        if not issubclass(cls.list_class, ChangeTrackingList):
+            raise TypeError(
+                f"list_class= expected Type[ChangeTrackingList], got {cls.list_class}"
+            )
+
+        return super().__init_subclass__(**kwargs)
+
     @overload
     def __get__(self, instance: None, owner: Type[Any]) -> SelfType: ...
 
     @overload
-    def __get__(self, instance: "Model", owner: Type[Any]) -> List[T_ORM]: ...
+    def __get__(self, instance: "Model", owner: Type[Any]) -> T_ORM_List: ...
 
     def __get__(
         self, instance: Optional["Model"], owner: Type[Any]
-    ) -> Union[SelfType, List[T_ORM]]:
+    ) -> Union[SelfType, T_ORM_List]:
         if not instance:
             return self
         return self._get_list_value(instance)
 
-    def _get_list_value(self, instance: "Model") -> List[T_ORM]:
+    def _get_list_value(self, instance: "Model") -> T_ORM_List:
         value = instance._fields.get(self.field_name)
         # If Airtable returns no value, substitute an empty list.
         if value is None:
             value = []
-        if self.readonly:
-            return value
 
         # We need to keep track of any mutations to this list, so we know
         # whether to write the field back to the API when the model is saved.
-        if not isinstance(value, ChangeNotifyingList):
-            on_change = partial(instance._changed.__setitem__, self.field_name, True)
-            value = ChangeNotifyingList[T_ORM](value, on_change=on_change)
+        if not isinstance(value, self.list_class):
+            # These were already checked in __init_subclass__ but mypy doesn't know that.
+            assert isinstance(self.list_class, type)
+            assert issubclass(self.list_class, ChangeTrackingList)
+            value = self.list_class(value, field=self, model=instance)
 
         # For implementers to be able to modify this list in place
         # and persist it later when they call .save(), we need to
         # set the list as the field's value.
         instance._fields[self.field_name] = value
-        return value
-
-
-class _ValidatingListField(Generic[T], _ListField[T, T]):
-    contains_type: Type[T]
+        return cast(T_ORM_List, value)
 
     def valid_or_raise(self, value: Any) -> None:
         super().valid_or_raise(value)
-        for obj in value:
-            if not isinstance(obj, self.contains_type):
-                raise TypeError(f"expected {self.contains_type}; got {type(obj)}")
+        if self.contains_type:
+            for obj in value:
+                if not isinstance(obj, self.contains_type):
+                    raise TypeError(f"expected {self.contains_type}; got {type(obj)}")
+
+
+class _ListField(Generic[T], _ListFieldBase[T, T, ChangeTrackingList[T]]):
+    """
+    Generic type for a field that stores a list of values.
+    Not for direct use; should be subclassed by concrete field types (below).
+    """
 
 
 class _LinkFieldOptions(Enum):
@@ -530,7 +564,10 @@ class _LinkFieldOptions(Enum):
 LinkSelf = _LinkFieldOptions.LinkSelf
 
 
-class LinkField(_ListField[RecordId, T_Linked]):
+class LinkField(
+    Generic[T_Linked],
+    _ListFieldBase[RecordId, T_Linked, ChangeTrackingList[T_Linked]],
+):
     """
     Represents a MultipleRecordLinks field. Returns and accepts lists of Models.
 
@@ -680,7 +717,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
             for value in records[: self._max_retrieve]
         ]
 
-    def _get_list_value(self, instance: "Model") -> List[T_Linked]:
+    def _get_list_value(self, instance: "Model") -> ChangeTrackingList[T_Linked]:
         """
         Unlike most other field classes, LinkField does not store its internal
         representation (T_ORM) in instance._fields after Model.from_record().
@@ -709,7 +746,7 @@ class LinkField(_ListField[RecordId, T_Linked]):
             # We could *try* to recursively save models that don't have an ID yet,
             # but that requires us to second-guess the implementers' intentions.
             # Better to just raise an exception.
-            raise ValueError(f"{self._description} contains an unsaved record")
+            raise UnsavedRecordError(f"{self._description} contains an unsaved record")
 
         return [v if isinstance(v, str) else v.id for v in value]
 
@@ -873,14 +910,20 @@ class AITextField(_DictField[AITextDict]):
     readonly = True
 
 
-class AttachmentsField(_ValidatingListField[AttachmentDict]):
+class AttachmentsField(
+    _ListFieldBase[
+        AttachmentDict,
+        Union[AttachmentDict, CreateAttachmentDict],
+        AttachmentsList,
+    ],
+    list_class=AttachmentsList,
+    contains_type=dict,
+):
     """
     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 BarcodeField(_DictField[BarcodeDict]):
     """
@@ -890,7 +933,7 @@ class BarcodeField(_DictField[BarcodeDict]):
     """
 
 
-class CollaboratorField(_DictField[CollaboratorDict]):
+class CollaboratorField(_DictField[Union[CollaboratorDict, CollaboratorEmailDict]]):
     """
     Accepts a `dict` that should conform to the format detailed in the
     `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`_
@@ -934,10 +977,8 @@ class ExternalSyncSourceField(TextField):
     readonly = True
 
 
-class LastModifiedByField(CollaboratorField):
+class LastModifiedByField(_DictField[CollaboratorDict]):
     """
-    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
-
     See `Last modified by <https://airtable.com/developers/web/api/field-model#lastmodifiedby>`__.
     """
 
@@ -954,7 +995,7 @@ class LastModifiedTimeField(DatetimeField):
     readonly = True
 
 
-class LookupField(Generic[T], _ListField[T, T]):
+class LookupField(Generic[T], _ListField[T]):
     """
     Generic field class for a lookup, which returns a list of values.
 
@@ -984,24 +1025,22 @@ class ManualSortField(TextField):
     readonly = True
 
 
-class MultipleCollaboratorsField(_ValidatingListField[CollaboratorDict]):
+class MultipleCollaboratorsField(
+    _ListField[Union[CollaboratorDict, CollaboratorEmailDict]], contains_type=dict
+):
     """
     Accepts a list of dicts in the format detailed in
     `Multiple Collaborators <https://airtable.com/developers/web/api/field-model#multicollaborator>`_.
     """
 
-    contains_type = cast(Type[CollaboratorDict], dict)
-
 
-class MultipleSelectField(_ValidatingListField[str]):
+class MultipleSelectField(_ListField[str], contains_type=str):
     """
     Accepts a list of ``str``.
 
     See `Multiple select <https://airtable.com/developers/web/api/field-model#multiselect>`__.
     """
 
-    contains_type = str
-
 
 class PercentField(NumberField):
     """
@@ -1147,7 +1186,7 @@ class RequiredBarcodeField(BarcodeField, _BasicFieldWithRequiredValue[BarcodeDic
     """
 
 
-class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[CollaboratorDict]):
+class RequiredCollaboratorField(CollaboratorField, _BasicFieldWithRequiredValue[Union[CollaboratorDict, CollaboratorEmailDict]]):
     """
     Accepts a `dict` that should conform to the format detailed in the
     `Collaborator <https://airtable.com/developers/web/api/field-model#collaborator>`_
@@ -1337,7 +1376,7 @@ class RequiredUrlField(UrlField, _BasicFieldWithRequiredValue[str]):
     """
 
 
-# [[[end]]] (checksum: 84b5c48286d992737e12318a72e4e123)
+# [[[end]]] (checksum: 5078434bb8fd65fa8f0be48de6915c2d)
 # fmt: on
 
 
@@ -1365,10 +1404,8 @@ class ButtonField(_DictField[ButtonDict], _BasicFieldWithRequiredValue[ButtonDic
     readonly = True
 
 
-class CreatedByField(RequiredCollaboratorField):
+class CreatedByField(_BasicFieldWithRequiredValue[CollaboratorDict]):
     """
-    Equivalent to :class:`CollaboratorField(readonly=True) <CollaboratorField>`.
-
     See `Created by <https://airtable.com/developers/web/api/field-model#createdby>`__.
 
     If the Airtable API returns ``null``, this field will raise :class:`~pyairtable.orm.fields.MissingValue`.
diff --git a/pyairtable/orm/generate.py b/pyairtable/orm/generate.py
index 6f7642b..a1e89cf 100644
--- a/pyairtable/orm/generate.py
+++ b/pyairtable/orm/generate.py
@@ -144,7 +144,7 @@ class FieldBuilder:
             try:
                 self.lookup[self.schema.options.linked_table_id]
             except KeyError:
-                return fields._ValidatingListField
+                return fields._ListField
         return fields.FIELD_TYPES_TO_CLASSES[field_type]
 
     def __str__(self) -> str:
@@ -162,7 +162,7 @@ class FieldBuilder:
             kwargs["model"] = linked_model.class_name
             generic = repr(linked_model.class_name)
 
-        if cls is fields._ValidatingListField:
+        if cls is fields._ListField:
             generic = "str"
 
         if self.schema.type in ("formula", "rollup"):
diff --git a/pyairtable/orm/lists.py b/pyairtable/orm/lists.py
new file mode 100644
index 0000000..f036063
--- /dev/null
+++ b/pyairtable/orm/lists.py
@@ -0,0 +1,141 @@
+from contextlib import contextmanager
+from pathlib import Path
+from typing import (
+    TYPE_CHECKING,
+    Iterable,
+    Iterator,
+    List,
+    Optional,
+    SupportsIndex,
+    Union,
+    overload,
+)
+
+from typing_extensions import Self, TypeVar
+
+from pyairtable.api.types import AttachmentDict, CreateAttachmentDict
+from pyairtable.exceptions import ReadonlyFieldError, UnsavedRecordError
+
+T = TypeVar("T")
+
+
+if TYPE_CHECKING:
+    # These would be circular imports if not for the TYPE_CHECKING condition.
+    from pyairtable.orm.fields import AnyField
+    from pyairtable.orm.model import Model
+
+
+class ChangeTrackingList(List[T]):
+    """
+    A list that keeps track of when its contents are modified. This allows us to know
+    if any mutations happened to the lists returned from linked record fields.
+    """
+
+    def __init__(self, *args: Iterable[T], field: "AnyField", model: "Model") -> None:
+        super().__init__(*args)
+        self._field = field
+        self._model = model
+        self._tracking_enabled = True
+
+    @contextmanager
+    def disable_tracking(self) -> Iterator[Self]:
+        """
+        Temporarily disable change tracking.
+        """
+        prev = self._tracking_enabled
+        self._tracking_enabled = False
+        try:
+            yield self
+        finally:
+            self._tracking_enabled = prev
+
+    def _on_change(self) -> None:
+        if self._tracking_enabled:
+            self._model._changed[self._field.field_name] = True
+
+    @overload
+    def __setitem__(self, index: SupportsIndex, value: T, /) -> None: ...
+
+    @overload
+    def __setitem__(self, key: slice, value: Iterable[T], /) -> None: ...
+
+    def __setitem__(
+        self,
+        index: Union[SupportsIndex, slice],
+        value: Union[T, Iterable[T]],
+        /,
+    ) -> None:
+        self._on_change()
+        return super().__setitem__(index, value)  # type: ignore
+
+    def __delitem__(self, key: Union[SupportsIndex, slice]) -> None:
+        self._on_change()
+        return super().__delitem__(key)
+
+    def append(self, object: T) -> None:
+        self._on_change()
+        return super().append(object)
+
+    def insert(self, index: SupportsIndex, object: T) -> None:
+        self._on_change()
+        return super().insert(index, object)
+
+    def remove(self, value: T) -> None:
+        self._on_change()
+        return super().remove(value)
+
+    def clear(self) -> None:
+        self._on_change()
+        return super().clear()
+
+    def extend(self, iterable: Iterable[T]) -> None:
+        self._on_change()
+        return super().extend(iterable)
+
+    def pop(self, index: SupportsIndex = -1) -> T:
+        self._on_change()
+        return super().pop(index)
+
+
+class AttachmentsList(ChangeTrackingList[Union[AttachmentDict, CreateAttachmentDict]]):
+    def upload(
+        self,
+        filename: Union[str, Path],
+        content: Optional[Union[str, bytes]] = None,
+        content_type: Optional[str] = None,
+    ) -> None:
+        """
+        Upload an attachment to the Airtable API and refresh the field's values.
+
+        This method will replace the current list with the response from the server,
+        which will contain a list of :class:`~pyairtable.api.types.AttachmentDict` for
+        all attachments in the field (not just the ones uploaded).
+
+        You do not need to call :meth:`~pyairtable.orm.Model.save`; the new attachment
+        will be saved immediately. Note that this means any other unsaved changes to
+        this field will be lost.
+
+        Example:
+            >>> model.attachments.upload("example.jpg", b"...", "image/jpeg")
+            >>> model.attachments[-1]["filename"]
+            'example.jpg'
+            >>> model.attachments[-1]["url"]
+            'https://v5.airtableusercontent.com/...'
+        """
+        if not self._model.id:
+            raise UnsavedRecordError("cannot upload attachments to an unsaved record")
+        if self._field.readonly:
+            raise ReadonlyFieldError("cannot upload attachments to a readonly field")
+        response = self._model.meta.table.upload_attachment(
+            self._model.id,
+            self._field.field_name,
+            filename=filename,
+            content=content,
+            content_type=content_type,
+        )
+        attachments = list(response["fields"].values()).pop(0)
+        with self.disable_tracking():
+            self.clear()
+            # We only ever expect one key: value in `response["fields"]`.
+            # See https://airtable.com/developers/web/api/upload-attachment
+            self.extend(attachments)
diff --git a/pyairtable/orm/model.py b/pyairtable/orm/model.py
index 12ceaec..5284a1c 100644
--- a/pyairtable/orm/model.py
+++ b/pyairtable/orm/model.py
@@ -684,6 +684,7 @@ class SaveResult:
             "Model.save() now returns SaveResult instead of bool; switch"
             " to checking Model.save().created instead before the 4.0 release.",
             DeprecationWarning,
+            stacklevel=2,
         )
         return self.created
 
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index c4d4420..510630f 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -1,6 +1,7 @@
 import inspect
 import re
 import textwrap
+import warnings
 from datetime import date, datetime
 from functools import partial, wraps
 from typing import (
@@ -20,7 +21,7 @@ from typing import (
 import requests
 from typing_extensions import ParamSpec, Protocol
 
-from pyairtable.api.types import CreateAttachmentDict
+from pyairtable.api.types import CreateAttachmentByUrl
 
 P = ParamSpec("P")
 R = TypeVar("R", covariant=True)
@@ -72,7 +73,7 @@ def date_from_iso_str(value: str) -> date:
     return datetime.strptime(value, "%Y-%m-%d").date()
 
 
-def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
+def attachment(url: str, filename: str = "") -> CreateAttachmentByUrl:
     """
     Build a ``dict`` in the expected format for creating attachments.
 
@@ -83,7 +84,7 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
     Note:
         Attachment field values **must** be an array of
         :class:`~pyairtable.api.types.AttachmentDict` or
-        :class:`~pyairtable.api.types.CreateAttachmentDict`;
+        :class:`~pyairtable.api.types.CreateAttachmentByUrl`;
         it is not valid to pass a single item to the API.
 
     Usage:
@@ -106,6 +107,11 @@ def attachment(url: str, filename: str = "") -> CreateAttachmentDict:
 
 
     """
+    warnings.warn(
+        "attachment(url, filename) is deprecated; use {'url': url, 'filename': filename} instead.",
+        DeprecationWarning,
+        stacklevel=2,
+    )
     return {"url": url} if not filename else {"url": url, "filename": filename}
 
 
@@ -253,3 +259,36 @@ def coerce_list_str(value: Optional[Union[str, Iterable[str]]]) -> List[str]:
     if isinstance(value, str):
         return [value]
     return list(value)
+
+
+# [[[cog]]]
+# import re
+# contents = "".join(open(cog.inFile).readlines()[:cog.firstLineNum])
+# functions = re.findall(r"^def ([a-z]\w+)\(", contents, re.MULTILINE)
+# partials = re.findall(r"^([A-Za-z]\w+) = partial\(", contents, re.MULTILINE)
+# constants = re.findall(r"^([A-Z][A-Z_]+) = ", contents, re.MULTILINE)
+# cog.outl("__all__ = [")
+# for name in sorted(functions + partials + constants):
+#     cog.outl(f'    "{name}",')
+# cog.outl("]")
+# [[[out]]]
+__all__ = [
+    "attachment",
+    "cache_unless_forced",
+    "chunked",
+    "coerce_iso_str",
+    "coerce_list_str",
+    "date_from_iso_str",
+    "date_to_iso_str",
+    "datetime_from_iso_str",
+    "datetime_to_iso_str",
+    "docstring_from",
+    "enterprise_only",
+    "is_airtable_id",
+    "is_base_id",
+    "is_field_id",
+    "is_record_id",
+    "is_table_id",
+    "is_user_id",
+]
+# [[[end]]] (checksum: 2e24ae7bd070c354cece2852ade7cdf9)


Test patch:
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 7541e33..b9b3e81 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -21,6 +21,7 @@ def cols():
         BOOL = "boolean"  # Boolean
         DATETIME = "datetime"  # Datetime
         ATTACHMENT = "attachment"  # attachment
+        ATTACHMENT_ID = "fld5VP9oPeCpvIumr"  # for upload_attachment
 
     return Columns
 
diff --git a/tests/integration/test_integration_api.py b/tests/integration/test_integration_api.py
index 65265e1..66656f4 100644
--- a/tests/integration/test_integration_api.py
+++ b/tests/integration/test_integration_api.py
@@ -1,11 +1,13 @@
 from datetime import datetime, timezone
+from unittest.mock import ANY
 from uuid import uuid4
 
 import pytest
+import requests
 
 from pyairtable import Table
 from pyairtable import formulas as fo
-from pyairtable.utils import attachment, date_to_iso_str, datetime_to_iso_str
+from pyairtable.utils import date_to_iso_str, datetime_to_iso_str
 
 pytestmark = [pytest.mark.integration]
 
@@ -277,23 +279,66 @@ def test_integration_formula_composition(table: Table, cols):
 
 
 def test_integration_attachment(table, cols, valid_img_url):
-    rec = table.create({cols.ATTACHMENT: [attachment(valid_img_url)]})
+    rec = table.create({cols.ATTACHMENT: [{"url": valid_img_url}]})
     rv_get = table.get(rec["id"])
-    assert rv_get["fields"]["attachment"][0]["url"].endswith("logo.png")
+    att = rv_get["fields"]["attachment"][0]
+    assert att["filename"] in (
+        valid_img_url.rpartition("/")[-1],  # sometimes taken from URL
+        "a." + valid_img_url.rpartition(".")[-1],  # default if not
+    )
+    original = requests.get(valid_img_url).content
+    attached = requests.get(att["url"]).content
+    assert original == attached
 
 
 def test_integration_attachment_multiple(table, cols, valid_img_url):
     rec = table.create(
         {
             cols.ATTACHMENT: [
-                attachment(valid_img_url, filename="a.jpg"),
-                attachment(valid_img_url, filename="b.jpg"),
+                {"url": valid_img_url, "filename": "a.png"},
+                {"url": valid_img_url, "filename": "b.png"},
             ]
         }
     )
     rv_get = table.get(rec["id"])
-    assert rv_get["fields"]["attachment"][0]["filename"] == "a.jpg"
-    assert rv_get["fields"]["attachment"][1]["filename"] == "b.jpg"
+    assert rv_get["fields"]["attachment"][0]["filename"] == "a.png"
+    assert rv_get["fields"]["attachment"][1]["filename"] == "b.png"
+
+
+def test_integration_upload_attachment(table, cols, valid_img_url, tmp_path):
+    rec = table.create({cols.ATTACHMENT: [{"url": valid_img_url, "filename": "a.png"}]})
+    content = requests.get(valid_img_url).content
+    response = table.upload_attachment(rec["id"], cols.ATTACHMENT, "b.png", content)
+    assert response == {
+        "id": rec["id"],
+        "createdTime": ANY,
+        "fields": {
+            cols.ATTACHMENT_ID: [
+                {
+                    "id": ANY,
+                    "url": ANY,
+                    "filename": "a.png",
+                    "type": "image/png",
+                    "size": 7297,
+                    # These exist because valid_img_url has been uploaded many, many times.
+                    "height": 400,
+                    "width": 400,
+                    "thumbnails": ANY,
+                },
+                {
+                    "id": ANY,
+                    "url": ANY,
+                    "filename": "b.png",
+                    "type": "image/png",
+                    "size": 7297,
+                    # These will not exist because we just uploaded the content.
+                    # "height": 400,
+                    # "width": 400,
+                    # "thumbnails": ANY,
+                },
+            ]
+        },
+    }
 
 
 def test_integration_comments(api, table: Table, cols):
diff --git a/tests/integration/test_integration_enterprise.py b/tests/integration/test_integration_enterprise.py
index 4deb2d4..f51a8ea 100644
--- a/tests/integration/test_integration_enterprise.py
+++ b/tests/integration/test_integration_enterprise.py
@@ -102,7 +102,7 @@ def test_create_field(blank_base: pyairtable.Base):
     assert len(table.schema().fields) == 1
     fld = table.create_field(
         "Status",
-        type="singleSelect",
+        field_type="singleSelect",
         options={
             "choices": [
                 {"name": "Todo"},
diff --git a/tests/integration/test_integration_orm.py b/tests/integration/test_integration_orm.py
index e859ef9..0114b04 100644
--- a/tests/integration/test_integration_orm.py
+++ b/tests/integration/test_integration_orm.py
@@ -138,11 +138,11 @@ def test_integration_orm(Contact, Address):
     )
 
     assert contact.first_name == "John"
-    assert contact.save()
+    assert contact.save().created
     assert contact.id
 
     contact.first_name = "Not Gui"
-    assert not contact.save()
+    assert not contact.save().created
 
     rv_address = contact.address[0]
     assert rv_address.exists()
@@ -251,3 +251,33 @@ def test_every_field(Everything):
     assert record.link_count == 1
     assert record.lookup_error == [{"error": "#ERROR!"}]
     assert record.lookup_integer == [record.formula_integer]
+
+
+def test_attachments_upload(Everything, valid_img_url, tmp_path):
+    record: _Everything = Everything()
+    record.save()
+
+    # add an attachment via URL
+    record.attachments.append({"url": valid_img_url, "filename": "logo.png"})
+    record.save()
+    assert record.attachments[0]["url"] == valid_img_url
+    record.fetch()
+    assert record.attachments[0]["id"] is not None
+    assert record.attachments[0]["filename"] == "logo.png"
+
+    # add an attachment by uploading content
+    tmp_file = tmp_path / "sample.txt"
+    tmp_file.write_text("Hello, World!")
+    record.attachments.upload(tmp_file)
+    # ensure we got all attachments, not just the latest one
+    assert record.attachments[0]["filename"] == "logo.png"
+    assert record.attachments[0]["type"] == "image/png"
+    assert record.attachments[1]["filename"] == "sample.txt"
+    assert record.attachments[1]["type"] == "text/plain"
+
+    # ensure everything persists/loads correctly after fetch()
+    record.fetch()
+    assert record.attachments[0]["filename"] == "logo.png"
+    assert record.attachments[0]["type"] == "image/png"
+    assert record.attachments[1]["filename"] == "sample.txt"
+    assert record.attachments[1]["type"] == "text/plain"
diff --git a/tests/test_api_table.py b/tests/test_api_table.py
index 34beedc..4a4f9d4 100644
--- a/tests/test_api_table.py
+++ b/tests/test_api_table.py
@@ -9,7 +9,7 @@ from requests_mock import Mocker
 from pyairtable import Api, Base, Table
 from pyairtable.formulas import AND, EQ, Field
 from pyairtable.models.schema import TableSchema
-from pyairtable.testing import fake_id, fake_record
+from pyairtable.testing import fake_attachment, fake_id, fake_record
 from pyairtable.utils import chunked
 
 NOW = datetime.now(timezone.utc).isoformat()
@@ -601,6 +601,53 @@ def test_use_field_ids__post(
     assert m.last_request.json()["returnFieldsByFieldId"] is False
 
 
+RECORD_ID = fake_id()
+FIELD_ID = fake_id("fld")
+
+
+@pytest.fixture
+def mock_upload_attachment(requests_mock, table):
+    return requests_mock.post(
+        f"https://content.airtable.com/v0/{table.base.id}/{RECORD_ID}/{FIELD_ID}/uploadAttachment",
+        status_code=200,
+        json={
+            "id": RECORD_ID,
+            "createdTime": NOW,
+            "fields": {FIELD_ID: [fake_attachment()]},
+        },
+    )
+
+
+@pytest.mark.parametrize("content", [b"Hello, World!", "Hello, World!"])
+def test_upload_attachment(mock_upload_attachment, table, content):
+    """
+    Test that we can upload an attachment to a record.
+    """
+    table.upload_attachment(RECORD_ID, FIELD_ID, "sample.txt", content)
+    assert mock_upload_attachment.last_request.json() == {
+        "contentType": "text/plain",
+        "file": "SGVsbG8sIFdvcmxkIQ==\n",  # base64 encoded "Hello, World!"
+        "filename": "sample.txt",
+    }
+
+
+def test_upload_attachment__no_content_type(mock_upload_attachment, table, tmp_path):
+    """
+    Test that we can upload an attachment to a record.
+    """
+    tmp_file = tmp_path / "sample_no_extension"
+    tmp_file.write_bytes(b"Hello, World!")
+
+    with pytest.warns(Warning, match="Could not guess content-type"):
+        table.upload_attachment(RECORD_ID, FIELD_ID, tmp_file)
+
+    assert mock_upload_attachment.last_request.json() == {
+        "contentType": "application/octet-stream",
+        "file": "SGVsbG8sIFdvcmxkIQ==\n",  # base64 encoded "Hello, World!"
+        "filename": "sample_no_extension",
+    }
+
+
 # Helpers
 
 
diff --git a/tests/test_api_types.py b/tests/test_api_types.py
index e124510..ede5a0d 100644
--- a/tests/test_api_types.py
+++ b/tests/test_api_types.py
@@ -14,7 +14,9 @@ from pyairtable.testing import fake_attachment, fake_id, fake_record, fake_user
         (T.ButtonDict, {"label": "My Button", "url": "http://example.com"}),
         (T.ButtonDict, {"label": "My Button", "url": None}),
         (T.CollaboratorDict, fake_user()),
-        (T.CreateAttachmentDict, {"url": "http://example.com", "filename": "test.jpg"}),
+        (T.CreateAttachmentById, {"id": "att123"}),
+        (T.CreateAttachmentByUrl, {"url": "http://example.com"}),
+        (T.CreateAttachmentDict, {"id": "att123"}),
         (T.CreateAttachmentDict, {"url": "http://example.com"}),
         (T.CreateRecordDict, {"fields": {}}),
         (T.RecordDeletedDict, {"deleted": True, "id": fake_id()}),
@@ -35,6 +37,15 @@ def test_assert_typed_dict(cls, value):
         T.assert_typed_dicts(cls, [value, -1])
 
 
+def test_assert_typed_dict__fail_union():
+    """
+    Test that we get the correct error message when assert_typed_dict
+    fails when called with a union of TypedDicts.
+    """
+    with pytest.raises(pydantic.ValidationError):
+        T.assert_typed_dict(T.CreateAttachmentDict, {"not": "good"})
+
+
 @pytest.mark.parametrize(
     "cls,value",
     [
@@ -42,7 +53,8 @@ def test_assert_typed_dict(cls, value):
         (T.BarcodeDict, {"type": "upc"}),
         (T.ButtonDict, {}),
         (T.CollaboratorDict, {}),
-        (T.CreateAttachmentDict, {}),
+        (T.CreateAttachmentById, {}),
+        (T.CreateAttachmentByUrl, {}),
         (T.CreateRecordDict, {}),
         (T.RecordDeletedDict, {}),
         (T.RecordDict, {}),
diff --git a/tests/test_orm_fields.py b/tests/test_orm_fields.py
index 63c4269..aed47ed 100644
--- a/tests/test_orm_fields.py
+++ b/tests/test_orm_fields.py
@@ -9,6 +9,7 @@ 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.lists import AttachmentsList
 from pyairtable.orm.model import Model
 from pyairtable.testing import (
     fake_attachment,
@@ -1070,3 +1071,67 @@ def test_select_field(fields, expected):
     with mock.patch("pyairtable.Table.update", return_value=obj.to_record()) as m:
         obj.save(force=True)
         m.assert_called_once_with(obj.id, fields, typecast=True)
+
+
+@pytest.mark.parametrize(
+    "class_kwargs",
+    [
+        {"contains_type": 1},
+        {"list_class": 1},
+        {"list_class": dict},
+    ],
+)
+def test_invalid_list_class_params(class_kwargs):
+    """
+    Test that certain parameters to ListField are invalid.
+    """
+
+    with pytest.raises(TypeError):
+
+        class ListFieldSubclass(f._ListField, **class_kwargs):
+            pass
+
+
+@mock.patch("pyairtable.Table.create")
+def test_attachments__set(mock_create):
+    """
+    Test that AttachmentsField can be set with a list of AttachmentDict,
+    and the value will be coerced to an AttachmentsList.
+    """
+    mock_create.return_value = {
+        "id": fake_id(),
+        "createdTime": DATETIME_S,
+        "fields": {
+            "Attachments": [
+                {
+                    "id": fake_id("att"),
+                    "url": "https://example.com",
+                    "filename": "a.jpg",
+                }
+            ]
+        },
+    }
+
+    class T(Model):
+        Meta = fake_meta()
+        attachments = f.AttachmentsField("Attachments")
+
+    obj = T()
+    assert obj.attachments == []
+    assert isinstance(obj.attachments, AttachmentsList)
+
+    obj.attachments = [{"url": "https://example.com"}]
+    assert isinstance(obj.attachments, AttachmentsList)
+
+    obj.save()
+    assert isinstance(obj.attachments, AttachmentsList)
+    assert obj.attachments[0]["url"] == "https://example.com"
+
+
+def test_attachments__set_invalid_type():
+    class T(Model):
+        Meta = fake_meta()
+        attachments = f.AttachmentsField("Attachments")
+
+    with pytest.raises(TypeError):
+        T().attachments = [1, 2, 3]
diff --git a/tests/test_orm_generate.py b/tests/test_orm_generate.py
index 3225540..1446bce 100644
--- a/tests/test_orm_generate.py
+++ b/tests/test_orm_generate.py
@@ -187,7 +187,7 @@ class Apartment(Model):
 
     name = F.TextField('Name')
     pictures = F.AttachmentsField('Pictures')
-    district = F._ValidatingListField[str]('District')
+    district = F._ListField[str]('District')
 
 
 __all__ = [
diff --git a/tests/test_orm_lists.py b/tests/test_orm_lists.py
new file mode 100644
index 0000000..90d11b3
--- /dev/null
+++ b/tests/test_orm_lists.py
@@ -0,0 +1,117 @@
+from datetime import datetime, timezone
+from unittest import mock
+
+import pytest
+
+from pyairtable.exceptions import ReadonlyFieldError, UnsavedRecordError
+from pyairtable.orm import fields as F
+from pyairtable.orm.model import Model
+from pyairtable.testing import fake_id, fake_meta, fake_record
+
+NOW = datetime.now(timezone.utc).isoformat()
+
+
+class Fake(Model):
+    Meta = fake_meta()
+    attachments = F.AttachmentsField("Files")
+    readonly_attachments = F.AttachmentsField("Other Files", readonly=True)
+
+
+@pytest.fixture
+def mock_upload():
+    response = {
+        "id": fake_id(),
+        "createdTime": NOW,
+        "fields": {
+            fake_id("fld"): [
+                {
+                    "id": fake_id("att"),
+                    "url": "https://example.com/a.txt",
+                    "filename": "a.txt",
+                    "type": "text/plain",
+                },
+            ],
+            # Test that, if Airtable's API returns multiple fields (for some reason),
+            # we will only use the first field in the "fields" key (not all of them).
+            fake_id("fld"): [
+                {
+                    "id": fake_id("att"),
+                    "url": "https://example.com/b.png",
+                    "filename": "b.png",
+                    "type": "image/png",
+                },
+            ],
+        },
+    }
+    with mock.patch("pyairtable.Table.upload_attachment", return_value=response) as m:
+        yield m
+
+
+@pytest.mark.parametrize("content", [b"Hello, world!", "Hello, world!"])
+def test_attachment_upload(mock_upload, tmp_path, content):
+    """
+    Test that we can add an attachment to a record.
+    """
+    fp = tmp_path / "a.txt"
+    writer = fp.write_text if isinstance(content, str) else fp.write_bytes
+    writer(content)
+
+    record = fake_record()
+    instance = Fake.from_record(record)
+    instance.attachments.upload(fp)
+    assert instance.attachments == [
+        {
+            "id": mock.ANY,
+            "url": "https://example.com/a.txt",
+            "filename": "a.txt",
+            "type": "text/plain",
+        },
+    ]
+
+    mock_upload.assert_called_once_with(
+        record["id"],
+        "Files",
+        filename=fp,
+        content=None,
+        content_type=None,
+    )
+
+
+def test_attachment_upload__readonly(mock_upload):
+    """
+    Test that calling upload() on a readonly field will raise an exception.
+    """
+    record = fake_record()
+    instance = Fake.from_record(record)
+    with pytest.raises(ReadonlyFieldError):
+        instance.readonly_attachments.upload("a.txt", content="Hello, world!")
+
+
+def test_attachment_upload__unsaved_record(mock_upload):
+    """
+    Test that calling upload() on an unsaved record will not call the API
+    and instead raises an exception.
+    """
+    instance = Fake()
+    with pytest.raises(UnsavedRecordError):
+        instance.attachments.upload("a.txt", content=b"Hello, world!")
+    mock_upload.assert_not_called()
+
+
+def test_attachment_upload__unsaved_value(mock_upload):
+    """
+    Test that calling upload() on an attachment list will clobber
+    any other unsaved changes made to that field.
+
+    This is not necessarily the most useful side effect, but it's the
+    only rational way to deal with the fact that Airtable will return
+    the full field value in its response, with no straightforward way
+    for us to identify the specific attachment that was uploaded.
+    """
+    instance = Fake.from_record(fake_record())
+    unsaved_url = "https://example.com/unsaved.txt"
+    instance.attachments = [{"url": unsaved_url}]
+    instance.attachments.upload("b.txt", content="Hello, world!")
+    mock_upload.assert_called_once()
+    assert len(instance.attachments) == 1
+    assert instance.attachments[0]["url"] != unsaved_url
diff --git a/tests/test_orm_model.py b/tests/test_orm_model.py
index 32d5eee..2e1559b 100644
--- a/tests/test_orm_model.py
+++ b/tests/test_orm_model.py
@@ -10,6 +10,8 @@ from pyairtable.orm import fields as f
 from pyairtable.orm.model import SaveResult
 from pyairtable.testing import fake_id, fake_meta, fake_record
 
+NOW = datetime.now(timezone.utc).isoformat()
+
 
 class FakeModel(Model):
     Meta = fake_meta()
diff --git a/tests/test_typing.py b/tests/test_typing.py
index 2078d5f..412542e 100644
--- a/tests/test_typing.py
+++ b/tests/test_typing.py
@@ -9,6 +9,7 @@ from typing_extensions import assert_type
 
 import pyairtable
 import pyairtable.api.types as T
+import pyairtable.orm.lists as L
 from pyairtable import orm
 
 if TYPE_CHECKING:
@@ -72,7 +73,14 @@ if TYPE_CHECKING:
         logins = orm.fields.MultipleCollaboratorsField("Logins")
 
     assert_type(Actor().name, str)
-    assert_type(Actor().logins, List[T.CollaboratorDict])
+    assert_type(
+        Actor().logins,
+        L.ChangeTrackingList[Union[T.CollaboratorDict, T.CollaboratorEmailDict]],
+    )
+    Actor().logins.append({"id": "usr123"})
+    Actor().logins.append({"email": "alice@example.com"})
+    Actor().logins = [{"id": "usr123"}]
+    Actor().logins = [{"email": "alice@example.com"}]
 
     class Movie(orm.Model):
         name = orm.fields.TextField("Name")
@@ -84,9 +92,10 @@ if TYPE_CHECKING:
     movie = Movie()
     assert_type(movie.name, str)
     assert_type(movie.rating, Optional[int])
-    assert_type(movie.actors, List[Actor])
-    assert_type(movie.prequels, List[Movie])
+    assert_type(movie.actors, L.ChangeTrackingList[Actor])
+    assert_type(movie.prequels, L.ChangeTrackingList[Movie])
     assert_type(movie.prequel, Optional[Movie])
+    assert_type(movie.actors[0], Actor)
     assert_type(movie.actors[0].name, str)
 
     class EveryField(orm.Model):
@@ -137,14 +146,17 @@ if TYPE_CHECKING:
         required_select = orm.fields.RequiredSelectField("Status")
         required_url = orm.fields.RequiredUrlField("URL")
 
+    # fmt: off
     record = EveryField()
     assert_type(record.aitext, Optional[T.AITextDict])
-    assert_type(record.attachments, List[T.AttachmentDict])
+    assert_type(record.attachments, L.AttachmentsList)
+    assert_type(record.attachments[0], Union[T.AttachmentDict, T.CreateAttachmentDict])
+    assert_type(record.attachments.upload("", b""), None)
     assert_type(record.autonumber, int)
     assert_type(record.barcode, Optional[T.BarcodeDict])
     assert_type(record.button, T.ButtonDict)
     assert_type(record.checkbox, bool)
-    assert_type(record.collaborator, Optional[T.CollaboratorDict])
+    assert_type(record.collaborator, Optional[Union[T.CollaboratorDict, T.CollaboratorEmailDict]])
     assert_type(record.count, Optional[int])
     assert_type(record.created_by, T.CollaboratorDict)
     assert_type(record.created, datetime.datetime)
@@ -157,8 +169,10 @@ if TYPE_CHECKING:
     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.multi_user, L.ChangeTrackingList[Union[T.CollaboratorDict, T.CollaboratorEmailDict]])
+    assert_type(record.multi_user[0], Union[T.CollaboratorDict, T.CollaboratorEmailDict])
+    assert_type(record.multi_select, L.ChangeTrackingList[str])
+    assert_type(record.multi_select[0], str)
     assert_type(record.number, Optional[Union[int, float]])
     assert_type(record.percent, Optional[Union[int, float]])
     assert_type(record.phone, str)
@@ -168,7 +182,7 @@ if TYPE_CHECKING:
     assert_type(record.url, str)
     assert_type(record.required_aitext, T.AITextDict)
     assert_type(record.required_barcode, T.BarcodeDict)
-    assert_type(record.required_collaborator, T.CollaboratorDict)
+    assert_type(record.required_collaborator, Union[T.CollaboratorDict, T.CollaboratorEmailDict])
     assert_type(record.required_count, int)
     assert_type(record.required_currency, Union[int, float])
     assert_type(record.required_date, datetime.date)
@@ -184,3 +198,18 @@ if TYPE_CHECKING:
     assert_type(record.required_rich_text, str)
     assert_type(record.required_select, str)
     assert_type(record.required_url, str)
+    # fmt: on
+
+    # Check that the type system allows create-style dicts in all places
+    record.attachments.append({"id": "att123"})
+    record.attachments.append({"url": "example.com"})
+    record.attachments.append({"url": "example.com", "filename": "a.jpg"})
+    record.attachments = [{"id": "att123"}]
+    record.attachments = [{"url": "example.com"}]
+    record.attachments = [{"url": "example.com", "filename": "a.jpg"}]
+    record.collaborator = {"id": "usr123"}
+    record.collaborator = {"email": "alice@example.com"}
+    record.required_collaborator = {"id": "usr123"}
+    record.required_collaborator = {"email": "alice@example.com"}
+    record.multi_user.append({"id": "usr123"})
+    record.multi_user.append({"email": "alice@example.com"})
diff --git a/tests/test_utils.py b/tests/test_utils.py
index f1d8f42..b18e28d 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -39,11 +39,14 @@ def test_date_utils(date_obj, date_str):
 
 
 def test_attachment():
-    assert utils.attachment("https://url.com") == {"url": "https://url.com"}
-    assert utils.attachment("https://url.com", filename="test.jpg") == {
-        "url": "https://url.com",
-        "filename": "test.jpg",
-    }
+    with pytest.deprecated_call():
+        assert utils.attachment("https://url.com") == {"url": "https://url.com"}
+
+    with pytest.deprecated_call():
+        assert utils.attachment("https://url.com", filename="test.jpg") == {
+            "url": "https://url.com",
+            "filename": "test.jpg",
+        }
 
 
 @pytest.mark.parametrize(